From cac3b4a467e62e72560668f253be3e522b015c24 Mon Sep 17 00:00:00 2001 From: Ben Poulson Date: Mon, 6 Apr 2026 23:13:55 +0700 Subject: [PATCH 1/2] Alignment with new SDK --- README.md | 119 ++-------- config/perfbase.php | 50 ++-- ..._125701_create_perfbase_profiles_table.php | 26 --- src/Caching/CacheStrategy.php | 52 ----- src/Caching/CacheStrategyFactory.php | 25 -- src/Caching/DatabaseStrategy.php | 120 ---------- src/Caching/FileStrategy.php | 148 ------------ src/Commands/PerfbaseClearCommand.php | 31 --- src/Commands/PerfbaseSyncCommand.php | 159 ------------- src/Facades/Perfbase.php | 2 +- src/Models/Profile.php | 75 ------ src/PerfbaseServiceProvider.php | 12 +- src/Profiling/AbstractProfiler.php | 39 ++-- src/Profiling/ConsoleProfiler.php | 135 ----------- src/Profiling/QueueProfiler.php | 98 -------- src/Support/PerfbaseConfig.php | 10 - tests/AbstractProfilerTest.php | 62 +---- tests/CacheStrategyFactoryTest.php | 66 ------ tests/ConsoleProfilerTest.php | 153 ------------ tests/FileStrategyTest.php | 221 ------------------ tests/PerfbaseConfigTest.php | 40 +--- tests/PerfbaseFacadeTest.php | 23 +- tests/PerfbaseMiddlewareTest.php | 6 +- tests/PerfbaseServiceProviderTest.php | 20 +- tests/QueueProfilerTest.php | 131 ----------- tests/UniversalProfilerTest.php | 6 +- 26 files changed, 90 insertions(+), 1739 deletions(-) delete mode 100644 database/migrations/2024_11_07_125701_create_perfbase_profiles_table.php delete mode 100644 src/Caching/CacheStrategy.php delete mode 100644 src/Caching/CacheStrategyFactory.php delete mode 100644 src/Caching/DatabaseStrategy.php delete mode 100644 src/Caching/FileStrategy.php delete mode 100644 src/Commands/PerfbaseClearCommand.php delete mode 100644 src/Commands/PerfbaseSyncCommand.php delete mode 100644 src/Models/Profile.php delete mode 100644 src/Profiling/ConsoleProfiler.php delete mode 100644 src/Profiling/QueueProfiler.php delete mode 100644 tests/CacheStrategyFactoryTest.php delete mode 100644 tests/ConsoleProfilerTest.php delete mode 100644 tests/FileStrategyTest.php delete mode 100644 tests/QueueProfilerTest.php diff --git a/README.md b/README.md index dacb0c7..42aaf94 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Seamless Laravel integration for Perfbase - a comprehensive Application Performa - ⚡ **Queue Job Profiling** - Track background job performance and failures - 🏷️ **Custom Attributes** - Add contextual metadata to traces - 🎯 **Smart Sampling** - Control data collection with configurable sample rates -- 💾 **Flexible Data Storage** - Sync immediately or buffer locally (file/database) +- 💾 **Reliable Delivery** - Explicit success/failure reporting on trace submission - 🔧 **Granular Control** - Include/exclude specific routes, commands, or jobs - 🛡️ **Multi-tenant Support** - Organization and project-level data isolation @@ -63,7 +63,6 @@ Add to your `.env` file: PERFBASE_ENABLED=true PERFBASE_API_KEY=your_api_key_here PERFBASE_SAMPLE_RATE=0.1 -PERFBASE_SENDING_MODE=sync ``` ### 5. Add Middleware (Optional but Recommended) @@ -100,17 +99,14 @@ The package auto-registers and provides several configuration options: // config/perfbase.php return [ 'enabled' => env('PERFBASE_ENABLED', false), + 'debug' => env('PERFBASE_DEBUG', false), + 'log_errors' => env('PERFBASE_LOG_ERRORS', true), 'api_key' => env('PERFBASE_API_KEY'), 'sample_rate' => env('PERFBASE_SAMPLE_RATE', 0.1), - - 'sending' => [ - 'mode' => env('PERFBASE_SENDING_MODE', 'sync'), - 'timeout' => env('PERFBASE_TIMEOUT', 5), - 'proxy' => env('PERFBASE_PROXY'), - ], - + 'timeout' => env('PERFBASE_TIMEOUT', 5), + 'proxy' => env('PERFBASE_PROXY'), 'flags' => env('PERFBASE_FLAGS', \Perfbase\SDK\FeatureFlags::DefaultFlags), - // ... more options + // ... include/exclude filters ]; ``` @@ -121,31 +117,12 @@ return [ | `PERFBASE_ENABLED` | `false` | Enable/disable profiling | | `PERFBASE_API_KEY` | `null` | Your Perfbase API key (required) | | `PERFBASE_SAMPLE_RATE` | `0.1` | Sampling rate (0.0 to 1.0) | -| `PERFBASE_SENDING_MODE` | `sync` | Data sending mode (`sync`, `file`, `database`) | +| `PERFBASE_DEBUG` | `false` | Enable debug mode (throws exceptions) | +| `PERFBASE_LOG_ERRORS` | `true` | Log profiling errors | | `PERFBASE_TIMEOUT` | `5` | API request timeout in seconds | | `PERFBASE_PROXY` | `null` | HTTP proxy URL | | `PERFBASE_FLAGS` | Default flags | Profiling feature flags | -### Sending Modes - -#### Sync Mode (Default) -Data is sent immediately to Perfbase: -```env -PERFBASE_SENDING_MODE=sync -``` - -#### File Buffering -Data is stored in local files and sent later: -```env -PERFBASE_SENDING_MODE=file -``` - -#### Database Buffering -Data is cached in your database and sent later: -```env -PERFBASE_SENDING_MODE=database -``` - ### Profiling Features Control Control which profiling features are enabled: @@ -183,11 +160,11 @@ Control which routes, commands, and jobs are profiled: 'api/*', 'admin/*' ], - 'console' => [ + 'artisan' => [ 'app:*', 'queue:*' ], - 'queue' => [ + 'jobs' => [ 'App\\Jobs\\*' ] ], @@ -197,11 +174,11 @@ Control which routes, commands, and jobs are profiled: 'health-check', '_debugbar/*' ], - 'console' => [ + 'artisan' => [ 'horizon:*', 'telescope:*' ], - 'queue' => [ + 'jobs' => [ 'App\\Jobs\\DebugJob' ] ] @@ -298,57 +275,8 @@ class User extends Authenticatable implements ProfiledUser } ``` -## Artisan Commands - -### Sync Buffered Data - -When using `file` or `database` sending modes, use this command to send buffered data: - -```bash -# Send all buffered trace data to Perfbase -php artisan perfbase:sync - -# Recommended: Set up a cron job -# * * * * * cd /path-to-your-project && php artisan perfbase:sync >> /dev/null 2>&1 -``` - -### Clear Buffered Data - -Remove all locally buffered traces: - -```bash -# Clear all buffered data (useful for debugging) -php artisan perfbase:clear -``` - ## Advanced Configuration -### Database Strategy Setup - -When using `database` sending mode, you may need to run the migration: - -```bash -php artisan migrate -``` - -The package includes a migration for the `perfbase_profiles` table. - -### Custom Cache Paths - -For file-based buffering, customize the storage path: - -```php -// config/perfbase.php -'sending' => [ - 'mode' => 'file', - 'config' => [ - 'file' => [ - 'path' => storage_path('app/perfbase-cache'), - ], - ], -], -``` - ### Performance Optimization For high-traffic applications: @@ -359,7 +287,6 @@ For high-traffic applications: 'flags' => \Perfbase\SDK\FeatureFlags::UseCoarseClock | \Perfbase\SDK\FeatureFlags::TrackCpuTime | \Perfbase\SDK\FeatureFlags::TrackPdo, -'sending' => ['mode' => 'file'], // Buffer locally ``` ### Multi-Environment Setup @@ -385,7 +312,7 @@ The Perfbase facade provides access to all SDK methods: | `stopTraceSpan($name)` | Stop profiling a named span | | `setAttribute($key, $value)` | Add attribute to current trace | | `setFlags($flags)` | Change profiling feature flags | -| `submitTrace()` | Submit trace data to Perfbase | +| `submitTrace()` | Submit trace data to Perfbase (returns `SubmitResult`) | | `getTraceData($spanName = '')` | Get raw trace data | | `reset()` | Clear current trace session | | `isExtensionAvailable()` | Check if extension is loaded | @@ -420,14 +347,6 @@ php --ini bash -c "$(curl -fsSL https://cdn.perfbase.com/install.sh)" ``` -### Permission Issues (File Mode) - -```bash -# Ensure storage directory is writable -chmod -R 755 storage/perfbase -chown -R www-data:www-data storage/perfbase -``` - ### High Memory Usage ```php @@ -437,17 +356,6 @@ chown -R www-data:www-data storage/perfbase 'sample_rate' => 0.01, // Lower sample rate ``` -### Database Issues (Database Mode) - -```bash -# Ensure migration is run -php artisan migrate - -# Check database connection -php artisan tinker ->>> DB::connection()->getPdo(); -``` - ## Testing When testing your Laravel application: @@ -471,7 +379,6 @@ public function test_something() - **Minimal Overhead**: ~1-3ms per request with default settings - **Sampling**: Use sample rates to reduce impact in production -- **Async Options**: File/database modes reduce request impact - **Selective Profiling**: Use include/exclude filters strategically ## Security Considerations diff --git a/config/perfbase.php b/config/perfbase.php index 9c2c5e7..258c8f1 100644 --- a/config/perfbase.php +++ b/config/perfbase.php @@ -25,6 +25,20 @@ */ 'enabled' => env('PERFBASE_ENABLED', false), + /* + |-------------------------------------------------------------------------- + | Debug Mode + |-------------------------------------------------------------------------- + */ + 'debug' => env('PERFBASE_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Log Errors + |-------------------------------------------------------------------------- + */ + 'log_errors' => env('PERFBASE_LOG_ERRORS', true), + /* |-------------------------------------------------------------------------- | API Key - Required - Used to authenticate your project with Perfbase. @@ -53,35 +67,17 @@ /* |-------------------------------------------------------------------------- - | Sending Configuration - Used to control when data is sent to Perfbase. + | HTTP Timeout - Timeout in seconds for API requests. |-------------------------------------------------------------------------- - | - | If you'd like to buffer data before sending it to Perfbase, you can configure - | the `mode` option in the transmission settings. The available mode values are: - | - 'sync': Sends data immediately without buffering. - | - 'file': Stores data in files before sending it to Perfbase. - | - 'database': Caches data in a database table before sending. - | - | When using modes other than 'sync', data will be collected locally. - | To process and send this buffered data to Perfbase, use the `perfbase:sync` - | Artisan command. It's recommended to set up a cron to periodically run it. - | */ - 'sending' => [ - 'mode' => env('PERFBASE_SENDING_MODE', 'sync'), - 'timeout' => env('PERFBASE_TIMEOUT', 5), - 'proxy' => env('PERFBASE_PROXY'), - 'config' => [ - 'sync' => [], - 'file' => [ - 'path' => storage_path('perfbase'), - ], - 'database' => [ - 'connection' => 'default', - 'table' => 'perfbase_cache', - ] - ] - ], + 'timeout' => env('PERFBASE_TIMEOUT', 5), + + /* + |-------------------------------------------------------------------------- + | HTTP Proxy - Optional proxy for API requests. + |-------------------------------------------------------------------------- + */ + 'proxy' => env('PERFBASE_PROXY'), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2024_11_07_125701_create_perfbase_profiles_table.php b/database/migrations/2024_11_07_125701_create_perfbase_profiles_table.php deleted file mode 100644 index eae7fc9..0000000 --- a/database/migrations/2024_11_07_125701_create_perfbase_profiles_table.php +++ /dev/null @@ -1,26 +0,0 @@ -create($table, function (Blueprint $table) { - $table->id(); - $table->longText('data'); - $table->timestamps(); - }); - } - - public function down(): void - { - $connection = config('perfbase.cache.config.database.connection'); - $table = config('perfbase.cache.config.database.table'); - Schema::connection($connection)->dropIfExists($table); - } -}; diff --git a/src/Caching/CacheStrategy.php b/src/Caching/CacheStrategy.php deleted file mode 100644 index cfc354b..0000000 --- a/src/Caching/CacheStrategy.php +++ /dev/null @@ -1,52 +0,0 @@ - $profileData The profile data to store - * @return void - */ - public function store(array $profileData): void; - - /** - * Get profiles that haven't been synced yet. - * - * @param int $chunk Maximum number of profiles to retrieve at once - * @return iterable>> - */ - public function getUnsentProfiles(int $chunk = 100): iterable; - - /** - * Count the number of unsent profiles in the cache. - * - * @return int - */ - public function countUnsentProfiles(): int; - - /** - * Delete a specific profile from the cache. - * - * @param mixed $id The profile identifier - * @return void - */ - public function delete($id): void; - - /** - * Delete multiple profiles from the cache. - * - * @param array $ids - * @return void - */ - public function deleteMass(array $ids): void; - - /** - * Clear all cached profiles. - * - * @return void - */ - public function clear(): void; -} \ No newline at end of file diff --git a/src/Caching/CacheStrategyFactory.php b/src/Caching/CacheStrategyFactory.php deleted file mode 100644 index 8ca2682..0000000 --- a/src/Caching/CacheStrategyFactory.php +++ /dev/null @@ -1,25 +0,0 @@ - $profileData The profile data to store - * @return void - */ - public function store(array $profileData): void - { - Profile::query()->create([ - 'data' => $profileData['data'] ?? serialize($profileData) - ]); - } - - /** - * Get profiles that haven't been synced yet. - * - * @param int $chunk Maximum number of profiles to retrieve at once - * @return iterable>> - */ - public function getUnsentProfiles(int $chunk = 100): iterable - { - $lastId = 0; - while (true) { - - /** @var Collection $profiles */ - $profiles = Profile::query() - ->where('id', '>', $lastId) - ->orderBy('id') - ->limit($chunk) - ->get(); - - if (!count($profiles)) { - break; - } - - /** @var Profile $last */ - $last = $profiles->last(); - - /** @var int $lastId */ - $lastId = $last->getKey(); - - /** @var array> $yield */ - $yield = []; - - /** @var Profile $profile */ - foreach ($profiles as $profile) { - - /** @var int $id */ - $id = $profile->getAttribute('id'); - - /** @var array $data */ - $data = $profile->getAttribute('data'); - - /** @var string $created_at */ - $created_at = $profile->getAttribute('created_at'); - - $yield[] = [ - 'id' => $id, - 'data' => $data, - 'created_at' => $created_at - ]; - } - - yield $yield; - } - } - - /** - * Count the number of unsent profiles in the cache. - * - * @return int - */ - public function countUnsentProfiles(): int - { - return Profile::query()->count(); - } - - /** - * Delete multiple profiles from the cache. - * - * @param array $ids - * @return void - */ - public function deleteMass(array $ids): void - { - Profile::query()->whereIn('id', $ids) - ->delete(); - } - - /** - * Delete a specific profile from the database. - * - * @param int $id The model id - * @return void - */ - public function delete($id): void - { - Profile::query()->where('id', $id) - ->delete(); - } - - /** - * Clear all profiles from the database. - * - * @return void - */ - public function clear(): void - { - Profile::query()->truncate(); - } -} \ No newline at end of file diff --git a/src/Caching/FileStrategy.php b/src/Caching/FileStrategy.php deleted file mode 100644 index e9df44d..0000000 --- a/src/Caching/FileStrategy.php +++ /dev/null @@ -1,148 +0,0 @@ -path = $path; - } - - /** - * Store a new profile as a file. - * - * @param array $profileData The profile data to store - * @return void - */ - public function store(array $profileData): void - { - if (!File::exists($this->path)) { - File::makeDirectory($this->path, 0755, true); - } - - $filename = Str::uuid() . $this->extension; - File::put($this->path . '/' . $filename, serialize([ - 'id' => $filename, - 'data' => $profileData, - 'created_at' => now()->toIso8601String(), - ])); - } - - /** - * Get profiles that haven't been synced yet. - * - * @param int $chunk Maximum number of profiles to retrieve at once - * @return iterable>> - */ - public function getUnsentProfiles(int $chunk = 100): iterable - { - $files = collect(File::files($this->path)); - - /** @var array> $fileChunks */ - $fileChunks = $files->chunk($chunk); - - // Yield each profile in the chunk - foreach ($fileChunks as $fileChunk) { - - /** @var array> $yield */ - $yield = []; - - foreach ($fileChunk as $file) { - - /** @var array $content */ - $content = unserialize(File::get($file)); - - /** @var string $data - This will be json data */ - $data = $content['data']; - - /** @var string $created_at */ - $created_at = $content['created_at']; - - $yield[] = [ - 'id' => $file, - 'data' => $data, - 'created_at' => $created_at - ]; - } - - yield $yield; - } - } - - /** - * Count the number of unsent profiles in the cache. - * - * @return int - */ - public function countUnsentProfiles(): int - { - return collect(File::files($this->path)) - ->filter(fn(SplFileInfo $file) => Str::endsWith($file->getFilename(), $this->extension)) - ->count(); - } - - /** - * Delete multiple profiles from the cache. - * - * @param array $ids - * @return void - */ - public function deleteMass(array $ids): void - { - foreach ($ids as $id) { - $this->delete($id); - } - } - - /** - * Delete a specific profile from the filesystem. - * - * @param string $id The file path - * @return void - */ - public function delete($id): void - { - $fullPath = $this->path . '/' . $id; - if (File::exists($fullPath)) { - File::delete($fullPath); - } - } - - /** - * Clear all profile files from the storage directory. - * - * @return void - */ - public function clear(): void - { - collect(File::files($this->path)) - ->filter(fn(SplFileInfo $file) => Str::endsWith($file->getFilename(), $this->extension)) - ->each(fn(SplFileInfo $file) => File::delete($file->getRealPath())); - } -} \ No newline at end of file diff --git a/src/Commands/PerfbaseClearCommand.php b/src/Commands/PerfbaseClearCommand.php deleted file mode 100644 index f8c347a..0000000 --- a/src/Commands/PerfbaseClearCommand.php +++ /dev/null @@ -1,31 +0,0 @@ -info('Clearing cached profiles...'); - $strategy = CacheStrategyFactory::make(); - $strategy->clear(); - $this->info('All cached profiles have been cleared.'); - return self::SUCCESS; - } -} diff --git a/src/Commands/PerfbaseSyncCommand.php b/src/Commands/PerfbaseSyncCommand.php deleted file mode 100644 index 0167a8a..0000000 --- a/src/Commands/PerfbaseSyncCommand.php +++ /dev/null @@ -1,159 +0,0 @@ -error('Perfbase is not configured to use a cache strategy'); - return self::FAILURE; - } - - $this->info(sprintf('Syncing profiles from %s to Perfbase API...', $strategy)); - - // Begin transaction if using database strategy - if ($strategy === 'database') { - DB::connection($this->getConnectionName())->beginTransaction(); - } - - try { - - // Get the cache strategy - $cache = CacheStrategyFactory::make(); - - // Check for unsent profiles - $this->info('Checking for unsent profiles...'); - $unsentCount = $cache->countUnsentProfiles(); - - // If there are no unsent profiles, we can skip the sync - if ($unsentCount === 0) { - $this->info('No unsent profiles found, nothing to sync!'); - return self::SUCCESS; - } - - $this->info(sprintf('Found %d unsent profiles, syncing...', $unsentCount)); - - /** @var Application $app */ - $app = app(); - - /** - * Initialize the Perfbase SDK client - * @var Config $config - */ - $config = $app->make(Config::class); - - $client = new ApiClient($config); - - /** - * IDs of profiles that have been synced. - * We send in batches to avoid memory issues, so we need to keep track of the IDs. - * @var array $ids - */ - $ids = []; - - try { - // Grab a chunk of profiles from the cache and send them to Perfbase - foreach ($cache->getUnsentProfiles(self::CHUNK_SIZE) as $profileChunk) { - - // Foreach profile - foreach ($profileChunk as $profile) { - - /** @var string $traceId */ - $traceId = $profile['id']; - if (!is_string($traceId)) { - throw new RuntimeException(sprintf('Found invalid `id` for profile ID: %s', $traceId)); - } - - /** @var string $traceData */ - $traceData = $profile['data']; - if (!is_array($traceData)) { - throw new RuntimeException(sprintf('Found invalid `data` for profile ID: %s', $traceId)); - } - - /** @var string $traceCreatedAt */ - $traceCreatedAt = $profile['created_at']; - if (!strtotime($traceCreatedAt)) { - throw new RuntimeException(sprintf('Found invalid `created_at` timestamp for profile ID: %s', $traceId)); - } - - // Submit to the API - $client->submitTrace($traceData); - - // Store the ID for deletion - $ids[] = $profile['id']; - } - - // Delete the chunk of profiles from the cache and clear the IDs array - $cache->deleteMass($ids); - - /** @var string $firstId */ - $firstId = $ids[0]; - - /** @var string $lastId */ - $lastId = $ids[count($ids) - 1]; - - $this->info(sprintf('Synced %d profiles, from profile %s to %s', count($ids), $firstId, $lastId)); - $ids = []; - - } - } catch (Throwable $e) { - $this->error($e->getMessage()); - if (!empty($ids)) { - $this->warn('An error occurred mid-sync, deleting the IDs that were synced'); - $cache->deleteMass($ids); - } - throw new RuntimeException('Error occurred during sync, halting.'); - } - } catch (Throwable $e) { - $this->error($e->getMessage()); - return self::FAILURE; - } finally { - // Finish up transaction if using database strategy - if ($strategy === 'database') { - // Finish up transaction - DB::connection($this->getConnectionName())->commit(); - } - } - - $this->info('Sync complete'); - - return self::SUCCESS; - } - - function getConnectionName(): string - { - $name = config('database.default'); - if (!is_string($name)) { - throw new RuntimeException('Invalid connection name'); - } - return $name; - } - -} diff --git a/src/Facades/Perfbase.php b/src/Facades/Perfbase.php index 7765343..19cd823 100644 --- a/src/Facades/Perfbase.php +++ b/src/Facades/Perfbase.php @@ -7,7 +7,7 @@ /** * @method static void startTraceSpan(string $spanName, array $attributes = []) * @method static bool stopTraceSpan(string $spanName) - * @method static void submitTrace() + * @method static \Perfbase\SDK\SubmitResult submitTrace() * @method static string getTraceData(string $spanName = '') * @method static void reset() * @method static bool isExtensionAvailable() diff --git a/src/Models/Profile.php b/src/Models/Profile.php deleted file mode 100644 index d404978..0000000 --- a/src/Models/Profile.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ - protected $fillable = [ - 'data' - ]; - - /** - * Get the database connection for the model. - * - * @return string - */ - public function getConnectionName() - { - $connection = config('perfbase.cache.config.database.connection'); - if (!is_string($connection)) { - throw new RuntimeException('Invalid connection name'); - } - return $connection; - } - - /** - * Get the table associated with the model. - * - * @return string - */ - public function getTable() - { - $table = config('perfbase.cache.config.database.table'); - if (!is_string($table)) { - throw new RuntimeException('Invalid table name'); - } - - return $table; - } - - /** - * Encode the data attribute into a base64 string before saving to the database. - * This is done because the data is in binary format. - * - * @param string $value - * @return void - */ - public function setDataAttribute(string $value): void - { - $this->attributes['data'] = base64_encode($value); - } - - /** - * Decode the data attribute back into binary data. - * - * @return string - */ - public function getDataAttribute(): string - { - $data = $this->attributes['data']; - if (!is_string($data)) { - throw new RuntimeException('Invalid data attribute'); - } - - return base64_decode($data); - } - -} diff --git a/src/PerfbaseServiceProvider.php b/src/PerfbaseServiceProvider.php index fbd4716..23871fe 100644 --- a/src/PerfbaseServiceProvider.php +++ b/src/PerfbaseServiceProvider.php @@ -12,8 +12,6 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Perfbase\Laravel\Profiling\AbstractProfiler; -use Perfbase\Laravel\Profiling\ConsoleProfiler; -use Perfbase\Laravel\Profiling\QueueProfiler; use Perfbase\Laravel\Profiling\UniversalProfiler; use Perfbase\Laravel\Support\PerfbaseConfig; use Perfbase\Laravel\Support\PerfbaseErrorHandling; @@ -46,12 +44,6 @@ public function boot() $this->publishes([ __DIR__ . '/../config/perfbase.php' => config_path('perfbase.php'), ], 'perfbase-config'); - - // Register commands - $this->commands([ - \Perfbase\Laravel\Commands\PerfbaseClearCommand::class, - \Perfbase\Laravel\Commands\PerfbaseSyncCommand::class, - ]); } if (PerfbaseConfig::enabled()) { @@ -82,10 +74,10 @@ public function register() $flags = $config['perfbase.flags']; /** @var string|null $proxy */ - $proxy = $config['perfbase.sending.proxy']; + $proxy = $config['perfbase.proxy']; /** @var numeric $timeout */ - $timeout = $config['perfbase.sending.timeout']; + $timeout = $config['perfbase.timeout']; /** @var string $apiKey */ $apiKey = $config['perfbase.api_key']; diff --git a/src/Profiling/AbstractProfiler.php b/src/Profiling/AbstractProfiler.php index bbcfeba..0ac8cce 100644 --- a/src/Profiling/AbstractProfiler.php +++ b/src/Profiling/AbstractProfiler.php @@ -3,8 +3,7 @@ namespace Perfbase\Laravel\Profiling; use Illuminate\Contracts\Container\BindingResolutionException; -use JsonException; -use Perfbase\Laravel\Caching\CacheStrategyFactory; +use Perfbase\Laravel\Support\PerfbaseErrorHandling; use Perfbase\SDK\Exception\PerfbaseException; use Perfbase\SDK\Exception\PerfbaseExtensionException; use Perfbase\SDK\Exception\PerfbaseInvalidSpanException; @@ -14,6 +13,8 @@ abstract class AbstractProfiler { + use PerfbaseErrorHandling; + /** @var PerfbaseClient */ protected PerfbaseClient $perfbase; @@ -52,7 +53,6 @@ private function getPerfbaseClient(): PerfbaseClient /** * Start profiling with the given context * - * @throws JsonException * @throws PerfbaseExtensionException * @throws PerfbaseInvalidSpanException * @throws PerfbaseException @@ -69,13 +69,15 @@ public function startProfiling(): void } /** - * Stop profiling and handle the trace data + * Stop profiling and submit the trace. + * + * On submission failure, the error is logged but not re-thrown + * so profiling never disrupts the application. * * @throws PerfbaseException */ public function stopProfiling(): void { - // Apply attributes foreach ($this->attributes as $key => $value) { $this->perfbase->setAttribute($key, $value); } @@ -84,22 +86,17 @@ public function stopProfiling(): void return; } - // Determine if we should send now or cache - $sendingMode = config('perfbase.sending.mode'); - $shouldSendNow = $sendingMode === 'sync'; - - if (!in_array($sendingMode, ['sync', 'database', 'file'], true)) { - throw new RuntimeException('Invalid sending mode specified in the configuration.'); - } - - if ($shouldSendNow) { - $this->perfbase->submitTrace(); - } else { - $cache = CacheStrategyFactory::make(); - $cache->store([ - 'data' => $this->perfbase->getTraceData(), - 'created_at' => now()->toDateTimeString(), - ]); + $result = $this->perfbase->submitTrace(); + + if (!$result->isSuccess()) { + $this->handleProfilingError( + new PerfbaseException(sprintf( + 'Trace submission failed (%s): %s', + $result->getStatus(), + $result->getMessage() + )), + 'submit' + ); } } diff --git a/src/Profiling/ConsoleProfiler.php b/src/Profiling/ConsoleProfiler.php deleted file mode 100644 index 987fd3a..0000000 --- a/src/Profiling/ConsoleProfiler.php +++ /dev/null @@ -1,135 +0,0 @@ -command = $command; - $this->input = $input; - $this->output = $output; - } - - public function setExitCode(int $exitCode): void - { - $this->setAttribute('exit_code', (string)$exitCode); - } - - /** - * Check to see if we should profile the command. - * - * @return bool - * @throws PerfbaseException - */ - protected function shouldProfile(): bool - { - if (!config('perfbase.enabled', false)) { - return false; - } - - $commandName = $this->getCommandName(); - - $includes = config('perfbase.include.console', []); - if (!is_array($includes)) { - throw new PerfbaseException('Configured perfbase console `includes` must be an array.'); - } - - if (!empty($includes) && !in_array($commandName, $includes, true)) { - return false; - } - - $excludes = config('perfbase.exclude.console', []); - if (!is_array($excludes)) { - throw new PerfbaseException('Configured perfbase console `excludes` must be an array.'); - } - - if (!empty($excludes) && in_array($commandName, $excludes, true)) { - return false; - } - - return true; - } - - /** - * Set the default attributes for the console trace. - * - * @return void - * @throws PerfbaseException - */ - protected function setDefaultAttributes(): void - { - parent::setDefaultAttributes(); - - // Add console specific attributes - $this->setAttributes([ - 'source' => 'console', - 'action' => $this->getCommandName(), - 'arguments' => json_encode($this->input->getArguments()) ?: '', - 'options' => json_encode($this->input->getOptions()) ?: '', - 'verbosity' => $this->getVerbosityLevel(), - ]); - } - - /** - * Return the name of the command being run. - * - * @return string - */ - private function getCommandName(): string - { - if ($this->command instanceof Command) { - return $this->command->getName() ?? ''; - } - return $this->command; - } - - /** - * Return the verbosity level of the command. - * - * @return string - */ - private function getVerbosityLevel(): string - { - $verbosity = $this->output->getVerbosity(); - switch ($verbosity) { - case OutputInterface::VERBOSITY_QUIET: - return 'quiet'; - case OutputInterface::VERBOSITY_NORMAL: - return 'normal'; - case OutputInterface::VERBOSITY_VERBOSE: - return 'verbose'; - case OutputInterface::VERBOSITY_VERY_VERBOSE: - return 'very_verbose'; - case OutputInterface::VERBOSITY_DEBUG: - return 'debug'; - default: - return 'unknown'; - } - } -} diff --git a/src/Profiling/QueueProfiler.php b/src/Profiling/QueueProfiler.php deleted file mode 100644 index c9f894f..0000000 --- a/src/Profiling/QueueProfiler.php +++ /dev/null @@ -1,98 +0,0 @@ -job = $job; - $this->jobName = $jobName; - } - - /** - * Set the exception message for the job. - * - * @param string $exception - * @return void - */ - public function setException(string $exception): void - { - $this->setAttribute('exception', $exception); - } - - /** - * Determine if the current context should be profiled - * - * @return bool - * @throws PerfbaseException - */ - protected function shouldProfile(): bool - { - if (!config('perfbase.enabled', false)) { - return false; - } - - $includes = config('perfbase.include.queue', []); - if (!is_array($includes)) { - throw new PerfbaseException('Configured perfbase queue `includes` must be an array.'); - } - - if (!empty($includes) && !in_array($this->jobName, $includes, true)) { - return false; - } - - $excludes = config('perfbase.exclude.queue', []); - if (!is_array($excludes)) { - throw new PerfbaseException('Configured perfbase queue `excludes` must be an array.'); - } - - if (!empty($excludes) && in_array($this->jobName, $excludes, true)) { - return false; - } - - return true; - } - - /** - * Set default attributes that should be included in every trace - * - * @return void - * @throws PerfbaseException - */ - protected function setDefaultAttributes(): void - { - parent::setDefaultAttributes(); - - // Add queue specific attributes - $this->setAttributes([ - 'source' => 'queue', - 'action' => $this->jobName, - 'queue' => $this->job->getQueue(), - 'attempts' => (string)($this->job->attempts() ?? 0), - 'connection' => $this->job->getConnectionName(), - 'job_id' => $this->job->getJobId(), - ]); - } -} diff --git a/src/Support/PerfbaseConfig.php b/src/Support/PerfbaseConfig.php index 098d599..b6f3864 100644 --- a/src/Support/PerfbaseConfig.php +++ b/src/Support/PerfbaseConfig.php @@ -46,16 +46,6 @@ public static function sampleRate(): float return self::get('sample_rate', 1.0); } - /** - * Get the sending mode - * - * @return string - */ - public static function sendingMode(): string - { - return self::get('sending.mode', 'sync'); - } - /** * Clear the cache (useful for testing) * diff --git a/tests/AbstractProfilerTest.php b/tests/AbstractProfilerTest.php index f4c52ed..f6990ba 100644 --- a/tests/AbstractProfilerTest.php +++ b/tests/AbstractProfilerTest.php @@ -4,13 +4,12 @@ require_once __DIR__ . '/TestHelpers.php'; -use Illuminate\Support\Facades\File; use Orchestra\Testbench\TestCase; -use Perfbase\Laravel\Caching\FileStrategy; use Perfbase\Laravel\Profiling\AbstractProfiler; use Perfbase\Laravel\PerfbaseServiceProvider; use Perfbase\SDK\Config; use Perfbase\SDK\Perfbase as PerfbaseClient; +use Perfbase\SDK\SubmitResult; use RuntimeException; use ReflectionClass; use Mockery; @@ -29,7 +28,6 @@ class AbstractProfilerTest extends TestCase private ConcreteProfiler $profiler; private ReflectionClass $reflection; private $perfbaseClient; - private string $testPath; protected function getPackageProviders($app): array { @@ -47,30 +45,19 @@ protected function setUp(): void $this->perfbaseClient->allows('startTraceSpan')->andReturns(true); $this->perfbaseClient->allows('stopTraceSpan')->andReturns(true); $this->perfbaseClient->allows('setAttribute')->andReturns(true); - $this->perfbaseClient->allows('submitTrace')->andReturns(true); + $this->perfbaseClient->allows('submitTrace')->andReturns(SubmitResult::success()); $this->perfbaseClient->allows('getTraceData')->andReturns('serialized_trace_data'); $this->perfbaseClient->allows('reset')->andReturns(true); $this->app->instance(Config::class, $config); $this->app->instance(PerfbaseClient::class, $this->perfbaseClient); - - // Set up file path for cache tests - $this->testPath = storage_path('testing/perfbase'); - + // Set up basic config config([ 'perfbase' => [ 'enabled' => true, 'api_key' => 'test-key', 'sample_rate' => 1.0, - 'sending' => [ - 'mode' => 'sync', - 'config' => [ - 'file' => [ - 'path' => $this->testPath - ] - ] - ], ], 'app' => [ 'env' => 'testing', @@ -84,10 +71,6 @@ protected function setUp(): void protected function tearDown(): void { - if (File::exists($this->testPath)) { - File::deleteDirectory($this->testPath); - } - Mockery::close(); parent::tearDown(); } @@ -211,52 +194,15 @@ protected function shouldProfile(): bool $this->assertTrue(true); // Explicit assertion } - public function testStopProfilingWithSyncMode() + public function testStopProfilingSubmitsTrace() { - config(['perfbase.sending.mode' => 'sync']); - // Just test that the method can be called without errors $this->profiler->setAttribute('test', 'value'); $this->profiler->stopProfiling(); - - // Verify config was set correctly - $this->assertEquals('sync', config('perfbase.sending.mode')); - $this->assertTrue(true); - } - public function testStopProfilingWithFileMode() - { - config(['perfbase.sending.mode' => 'file']); - - // Mock File facade to avoid actual file operations - File::shouldReceive('exists') - ->andReturn(false); - File::shouldReceive('makeDirectory') - ->andReturn(true); - File::shouldReceive('put') - ->andReturn(true); - - $this->profiler->stopProfiling(); - - // Verify config was set correctly - $this->assertEquals('file', config('perfbase.sending.mode')); $this->assertTrue(true); } - public function testStopProfilingWithInvalidSendingMode() - { - config(['perfbase.sending.mode' => 'invalid']); - - // Test that invalid mode is set - $this->assertEquals('invalid', config('perfbase.sending.mode')); - - // Expect the RuntimeException for invalid sending mode - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Invalid sending mode specified in the configuration.'); - - $this->profiler->stopProfiling(); - } - public function testStopProfilingWhenSpanNotStarted() { // Just test that method can be called diff --git a/tests/CacheStrategyFactoryTest.php b/tests/CacheStrategyFactoryTest.php deleted file mode 100644 index d1ef68b..0000000 --- a/tests/CacheStrategyFactoryTest.php +++ /dev/null @@ -1,66 +0,0 @@ - 'database']); - - $strategy = CacheStrategyFactory::make(); - - $this->assertInstanceOf(DatabaseStrategy::class, $strategy); - } - - public function testMakeFileStrategy() - { - config(['perfbase.sending.mode' => 'file']); - - $strategy = CacheStrategyFactory::make(); - - $this->assertInstanceOf(FileStrategy::class, $strategy); - } - - public function testMakeInvalidStrategyThrowsException() - { - config(['perfbase.sending.mode' => 'invalid']); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Invalid cache strategy'); - - CacheStrategyFactory::make(); - } - - public function testMakeWithSyncModeThrowsException() - { - config(['perfbase.sending.mode' => 'sync']); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Invalid cache strategy'); - - CacheStrategyFactory::make(); - } - - public function testMakeWithNullModeThrowsException() - { - config(['perfbase.sending.mode' => null]); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Invalid cache strategy'); - - CacheStrategyFactory::make(); - } -} \ No newline at end of file diff --git a/tests/ConsoleProfilerTest.php b/tests/ConsoleProfilerTest.php deleted file mode 100644 index 6b3bb7b..0000000 --- a/tests/ConsoleProfilerTest.php +++ /dev/null @@ -1,153 +0,0 @@ -allows('isExtensionAvailable')->andReturns(true); - $perfbaseClient->allows('startTraceSpan')->andReturns(true); - $perfbaseClient->allows('stopTraceSpan')->andReturns(true); - $perfbaseClient->allows('setAttribute')->andReturns(true); - $perfbaseClient->allows('submitTrace')->andReturns(true); - $perfbaseClient->allows('getTraceData')->andReturns('test-data'); - $perfbaseClient->allows('reset')->andReturns(true); - $this->app->instance(Config::class, $config); - $this->app->instance(PerfbaseClient::class, $perfbaseClient); - - // Set up basic config values needed for the test - config([ - 'perfbase' => [ - 'enabled' => true, - 'api_key' => 'test-key', - 'sample_rate' => 1.0, - 'sending' => [ - 'mode' => 'sync', - 'timeout' => 5, - ], - 'include' => [ - 'console' => [], - ], - 'exclude' => [ - 'console' => [], - ], - ] - ]); - - $this->command = new class extends Command { - protected $signature = 'test:command {arg} {--option=}'; - public function handle() {} - }; - - $this->input = new ArrayInput(['arg' => 'value', '--option' => 'opt']); - $this->output = new ConsoleOutput(); - $this->output->setVerbosity(OutputInterface::VERBOSITY_NORMAL); - - $this->profiler = new ConsoleProfiler( - $this->command, - $this->input, - $this->output - ); - $this->reflection = new ReflectionClass(ConsoleProfiler::class); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function testConstructor() - { - $this->assertEquals('console', $this->getPrivateProperty('spanName')); - } - - public function testSetExitCode() - { - $this->profiler->setExitCode(1); - $this->assertEquals('1', $this->getPrivateProperty('attributes')['exit_code']); - } - - public function testShouldProfileWhenDisabled() - { - config(['perfbase.enabled' => false]); - $this->assertFalse($this->callPrivateMethod('shouldProfile')); - } - - public function testShouldProfileWhenEnabled() - { - config(['perfbase.enabled' => true]); - config(['perfbase.include.console' => ['test:command']]); - config(['perfbase.exclude.console' => []]); - - $this->assertTrue($this->callPrivateMethod('shouldProfile')); - } - - public function testGetCommandName() - { - $this->assertEquals('test:command', $this->callPrivateMethod('getCommandName')); - } - - public function testGetVerbosityLevel() - { - $this->assertEquals('normal', $this->callPrivateMethod('getVerbosityLevel')); - } - - public function testSetDefaultAttributes() - { - $this->callPrivateMethod('setDefaultAttributes'); - $attributes = $this->getPrivateProperty('attributes'); - - $this->assertEquals('test:command', $attributes['action']); - $this->assertArrayHasKey('arguments', $attributes); - $this->assertArrayHasKey('options', $attributes); - $this->assertEquals('normal', $attributes['verbosity']); - } - - private function callPrivateMethod(string $methodName, array $args = []) - { - $method = $this->reflection->getMethod($methodName); - $method->setAccessible(true); - return $method->invokeArgs($this->profiler, $args); - } - - private function getPrivateProperty(string $propertyName) - { - $property = $this->reflection->getProperty($propertyName); - $property->setAccessible(true); - return $property->getValue($this->profiler); - } -} diff --git a/tests/FileStrategyTest.php b/tests/FileStrategyTest.php deleted file mode 100644 index a63fca1..0000000 --- a/tests/FileStrategyTest.php +++ /dev/null @@ -1,221 +0,0 @@ -testPath = storage_path('testing/perfbase'); - - // Set up file configuration - config([ - 'perfbase.sending.config.file.path' => $this->testPath - ]); - - // Clean up and create test directory - if (File::exists($this->testPath)) { - File::deleteDirectory($this->testPath); - } - File::makeDirectory($this->testPath, 0755, true); - - $this->strategy = new FileStrategy(); - } - - protected function tearDown(): void - { - // Clean up test directory - if (File::exists($this->testPath)) { - File::deleteDirectory($this->testPath); - } - - parent::tearDown(); - } - - public function testConstructorThrowsExceptionWithInvalidPath() - { - config(['perfbase.sending.config.file.path' => null]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The file cache path must be a string'); - - new FileStrategy(); - } - - public function testStore() - { - $profileData = ['trace' => 'test_data', 'timestamp' => time()]; - - $this->strategy->store($profileData); - - $files = File::files($this->testPath); - $this->assertCount(1, $files); - - $file = $files[0]; - $this->assertStringEndsWith('.perfbase', $file->getFilename()); - - $content = unserialize(File::get($file)); - $this->assertArrayHasKey('id', $content); - $this->assertArrayHasKey('data', $content); - $this->assertArrayHasKey('created_at', $content); - $this->assertEquals($profileData, $content['data']); - } - - public function testStoreCreatesDirectoryIfNotExists() - { - // Delete the directory - File::deleteDirectory($this->testPath); - $this->assertFalse(File::exists($this->testPath)); - - $profileData = ['trace' => 'test_data']; - $this->strategy->store($profileData); - - $this->assertTrue(File::exists($this->testPath)); - $this->assertCount(1, File::files($this->testPath)); - } - - public function testGetUnsentProfiles() - { - // Create test profiles - $profiles = []; - for ($i = 0; $i < 5; $i++) { - $data = ['trace' => "test_data_$i", 'index' => $i]; - $this->strategy->store($data); - $profiles[] = $data; - } - - // Get all profiles - $retrievedProfiles = []; - foreach ($this->strategy->getUnsentProfiles() as $chunk) { - foreach ($chunk as $profile) { - $retrievedProfiles[] = $profile; - } - } - - $this->assertCount(5, $retrievedProfiles); - - // Verify structure - foreach ($retrievedProfiles as $profile) { - $this->assertArrayHasKey('id', $profile); - $this->assertArrayHasKey('data', $profile); - $this->assertArrayHasKey('created_at', $profile); - } - } - - public function testGetUnsentProfilesChunking() - { - // Create 10 profiles - for ($i = 0; $i < 10; $i++) { - $this->strategy->store(['index' => $i]); - } - - // Get profiles in chunks of 3 - $chunks = []; - foreach ($this->strategy->getUnsentProfiles(3) as $chunk) { - $chunks[] = $chunk; - } - - // Should have 4 chunks: 3, 3, 3, 1 - $this->assertCount(4, $chunks); - $this->assertCount(3, $chunks[0]); - $this->assertCount(3, $chunks[1]); - $this->assertCount(3, $chunks[2]); - $this->assertCount(1, $chunks[3]); - } - - public function testCountUnsentProfiles() - { - $this->assertEquals(0, $this->strategy->countUnsentProfiles()); - - $this->strategy->store(['test' => 'data1']); - $this->strategy->store(['test' => 'data2']); - $this->strategy->store(['test' => 'data3']); - - $this->assertEquals(3, $this->strategy->countUnsentProfiles()); - } - - public function testDelete() - { - $this->strategy->store(['test' => 'data']); - $files = File::files($this->testPath); - $this->assertCount(1, $files); - - $filename = $files[0]->getFilename(); - $this->strategy->delete($filename); - - $this->assertCount(0, File::files($this->testPath)); - } - - public function testDeleteNonExistentFile() - { - // Should not throw exception - $this->strategy->delete('non-existent-file.perfbase'); - $this->assertTrue(true); // Assert no exception was thrown - } - - public function testDeleteMass() - { - // Create 5 files - $filenames = []; - for ($i = 0; $i < 5; $i++) { - $this->strategy->store(['index' => $i]); - } - - $files = File::files($this->testPath); - foreach ($files as $file) { - $filenames[] = $file->getFilename(); - } - - $this->assertCount(5, $files); - - // Delete first 3 - $this->strategy->deleteMass(array_slice($filenames, 0, 3)); - - $this->assertCount(2, File::files($this->testPath)); - } - - public function testClear() - { - // Create multiple profiles - for ($i = 0; $i < 5; $i++) { - $this->strategy->store(['index' => $i]); - } - - // Create a non-perfbase file that should not be deleted - File::put($this->testPath . '/other-file.txt', 'content'); - - $this->assertCount(6, File::files($this->testPath)); // 5 .perfbase + 1 .txt - - $this->strategy->clear(); - - $files = File::files($this->testPath); - $this->assertCount(1, $files); // Only the .txt file should remain - $this->assertEquals('other-file.txt', $files[0]->getFilename()); - } - - public function testEmptyGetUnsentProfiles() - { - $profiles = []; - foreach ($this->strategy->getUnsentProfiles() as $chunk) { - $profiles = array_merge($profiles, $chunk); - } - - $this->assertEmpty($profiles); - } -} \ No newline at end of file diff --git a/tests/PerfbaseConfigTest.php b/tests/PerfbaseConfigTest.php index 4b84767..3cabccc 100644 --- a/tests/PerfbaseConfigTest.php +++ b/tests/PerfbaseConfigTest.php @@ -180,47 +180,20 @@ public function testSampleRateMethodWithInteger() $this->assertEquals(1.0, PerfbaseConfig::sampleRate()); } - public function testSendingModeMethod() - { - config(['perfbase.sending.mode' => 'async']); - PerfbaseConfig::clearCache(); - - $this->assertEquals('async', PerfbaseConfig::sendingMode()); - } - - public function testSendingModeMethodDefault() - { - // Don't set perfbase.sending.mode - PerfbaseConfig::clearCache(); - - $this->assertEquals('sync', PerfbaseConfig::sendingMode()); // Should default to 'sync' - } - - public function testSendingModeMethodWithNestedConfig() - { - config(['perfbase.sending' => ['mode' => 'database', 'timeout' => 10]]); - PerfbaseConfig::clearCache(); - - $this->assertEquals('database', PerfbaseConfig::sendingMode()); - } - public function testMultipleCallsUseSameCache() { config([ 'perfbase.enabled' => true, 'perfbase.sample_rate' => 0.8, - 'perfbase.sending.mode' => 'file' ]); PerfbaseConfig::clearCache(); - + // Multiple calls to different methods $enabled = PerfbaseConfig::enabled(); $sampleRate = PerfbaseConfig::sampleRate(); - $sendingMode = PerfbaseConfig::sendingMode(); - + $this->assertTrue($enabled); $this->assertEquals(0.8, $sampleRate); - $this->assertEquals('file', $sendingMode); } public function testCacheIsSharedAcrossMethods() @@ -331,18 +304,17 @@ public function testShortcutMethodsUseCaching() config([ 'perfbase.enabled' => true, 'perfbase.sample_rate' => 0.75, - 'perfbase.sending.mode' => 'database' ]); - + // First call to enabled() should populate cache PerfbaseConfig::enabled(); - + // Change config config(['perfbase.enabled' => false]); - + // Second call should return cached value $this->assertTrue(PerfbaseConfig::enabled()); - + // Clear cache and try again PerfbaseConfig::clearCache(); $this->assertFalse(PerfbaseConfig::enabled()); diff --git a/tests/PerfbaseFacadeTest.php b/tests/PerfbaseFacadeTest.php index 2e6993f..9532a7d 100644 --- a/tests/PerfbaseFacadeTest.php +++ b/tests/PerfbaseFacadeTest.php @@ -8,6 +8,7 @@ use Perfbase\SDK\Config; use Perfbase\SDK\Extension\ExtensionInterface; use Perfbase\SDK\Perfbase as PerfbaseClient; +use Perfbase\SDK\SubmitResult; use Mockery; class PerfbaseFacadeTest extends TestCase @@ -37,10 +38,8 @@ protected function setUp(): void 'enabled' => true, 'api_key' => 'test-key', 'flags' => 0, - 'sending' => [ - 'proxy' => null, - 'timeout' => 5, - ], + 'proxy' => null, + 'timeout' => 5, ] ]); } @@ -105,16 +104,14 @@ public function testStopTraceSpanMethod() public function testSubmitTraceMethod() { - // Mock the Perfbase client $mockClient = Mockery::mock(PerfbaseClient::class); - $mockClient->shouldReceive('submitTrace')->once()->andReturn(); - + $mockClient->shouldReceive('submitTrace')->once()->andReturn(SubmitResult::success()); + $this->app->instance(PerfbaseClient::class, $mockClient); - - Perfbase::submitTrace(); - - $mockClient->shouldHaveReceived('submitTrace')->once(); - $this->assertTrue(true); // Assert that we got this far + + $result = Perfbase::submitTrace(); + + $this->assertTrue($result->isSuccess()); } public function testGetTraceDataMethod() @@ -284,7 +281,7 @@ public function testFacadeDocBlocks() $this->assertStringContainsString('@method static void startTraceSpan(string $spanName, array $attributes = [])', $docComment); $this->assertStringContainsString('@method static bool stopTraceSpan(string $spanName)', $docComment); - $this->assertStringContainsString('@method static void submitTrace()', $docComment); + $this->assertStringContainsString('@method static \Perfbase\SDK\SubmitResult submitTrace()', $docComment); $this->assertStringContainsString('@method static string getTraceData(string $spanName = \'\')', $docComment); $this->assertStringContainsString('@method static void reset()', $docComment); $this->assertStringContainsString('@method static bool isExtensionAvailable()', $docComment); diff --git a/tests/PerfbaseMiddlewareTest.php b/tests/PerfbaseMiddlewareTest.php index 2d97748..9daed91 100644 --- a/tests/PerfbaseMiddlewareTest.php +++ b/tests/PerfbaseMiddlewareTest.php @@ -9,6 +9,7 @@ use Perfbase\Laravel\PerfbaseServiceProvider; use Perfbase\SDK\Config; use Perfbase\SDK\Perfbase as PerfbaseClient; +use Perfbase\SDK\SubmitResult; use Mockery; class PerfbaseMiddlewareTest extends TestCase @@ -32,7 +33,7 @@ protected function setUp(): void $this->perfbaseClient->allows('startTraceSpan')->andReturns(true); $this->perfbaseClient->allows('stopTraceSpan')->andReturns(true); $this->perfbaseClient->allows('setAttribute')->andReturns(true); - $this->perfbaseClient->allows('submitTrace')->andReturns(true); + $this->perfbaseClient->allows('submitTrace')->andReturns(SubmitResult::success()); $this->perfbaseClient->allows('getTraceData')->andReturns(['trace' => 'data']); $this->perfbaseClient->allows('reset')->andReturns(true); @@ -45,9 +46,6 @@ protected function setUp(): void 'enabled' => true, 'api_key' => 'test-key', 'sample_rate' => 1.0, - 'sending' => [ - 'mode' => 'sync', - ], 'include' => [ 'http' => ['*'], ], diff --git a/tests/PerfbaseServiceProviderTest.php b/tests/PerfbaseServiceProviderTest.php index e95ae6e..6e25197 100644 --- a/tests/PerfbaseServiceProviderTest.php +++ b/tests/PerfbaseServiceProviderTest.php @@ -44,10 +44,8 @@ protected function setUp(): void 'enabled' => true, 'api_key' => 'test-key', 'flags' => 0, - 'sending' => [ - 'proxy' => null, - 'timeout' => 5, - ], + 'proxy' => null, + 'timeout' => 5, ] ]); } @@ -93,7 +91,7 @@ public function testServiceProviderMergesConfig() $this->assertArrayHasKey('enabled', $config); $this->assertArrayHasKey('api_key', $config); $this->assertArrayHasKey('sample_rate', $config); - $this->assertArrayHasKey('sending', $config); + $this->assertArrayHasKey('timeout', $config); $this->assertArrayHasKey('include', $config); $this->assertArrayHasKey('exclude', $config); } @@ -176,19 +174,19 @@ public function testServiceProviderPublishesConfig() public function testConfigBindingHandlesNullProxy() { - config(['perfbase.sending.proxy' => null]); - + config(['perfbase.proxy' => null]); + $config = $this->app->make(Config::class); - + $this->assertInstanceOf(Config::class, $config); } public function testConfigBindingHandlesStringProxy() { - config(['perfbase.sending.proxy' => 'http://proxy.example.com']); - + config(['perfbase.proxy' => 'http://proxy.example.com']); + $config = $this->app->make(Config::class); - + $this->assertInstanceOf(Config::class, $config); } diff --git a/tests/QueueProfilerTest.php b/tests/QueueProfilerTest.php deleted file mode 100644 index d95b3e8..0000000 --- a/tests/QueueProfilerTest.php +++ /dev/null @@ -1,131 +0,0 @@ -allows('isExtensionAvailable')->andReturns(true); - $perfbaseClient->allows('startTraceSpan')->andReturns(true); - $perfbaseClient->allows('stopTraceSpan')->andReturns(true); - $perfbaseClient->allows('setAttribute')->andReturns(true); - $perfbaseClient->allows('submitTrace')->andReturns(true); - $perfbaseClient->allows('getTraceData')->andReturns('test-data'); - $perfbaseClient->allows('reset')->andReturns(true); - $this->app->instance(Config::class, $config); - $this->app->instance(PerfbaseClient::class, $perfbaseClient); - - // Set up basic config values needed for the test - config([ - 'perfbase' => [ - 'enabled' => true, - 'api_key' => 'test-key', - 'sample_rate' => 1.0, - 'sending' => [ - 'mode' => 'sync', - 'timeout' => 5, - ], - 'include' => [ - 'queue' => [], - ], - 'exclude' => [ - 'queue' => [], - ], - ] - ]); - - // Mock the job - $this->job = Mockery::mock(Job::class); - $this->job->shouldReceive('getName')->andReturn('App\Jobs\TestJob'); - $this->job->shouldReceive('getQueue')->andReturn('default'); - $this->job->shouldReceive('attempts')->andReturn(1); - $this->job->shouldReceive('getConnectionName')->andReturn('redis'); - $this->job->shouldReceive('getJobId')->andReturn('123'); - - $this->profiler = new QueueProfiler($this->job, 'App\Jobs\TestJob'); - $this->reflection = new ReflectionClass(QueueProfiler::class); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function testConstructor() - { - $this->assertEquals('App\Jobs\TestJob', $this->getPrivateProperty('spanName')); - } - - public function testShouldProfileWhenDisabled() - { - config(['perfbase.enabled' => false]); - $this->assertFalse($this->callPrivateMethod('shouldProfile')); - } - - public function testShouldProfileWhenEnabled() - { - config(['perfbase.enabled' => true]); - config(['perfbase.include.queue' => ['App\Jobs\TestJob']]); - config(['perfbase.exclude.queue' => []]); - - $this->assertTrue($this->callPrivateMethod('shouldProfile')); - } - - public function testGetJobName() - { - $this->assertEquals('App\Jobs\TestJob', $this->getPrivateProperty('jobName')); - } - - public function testSetDefaultAttributes() - { - $this->callPrivateMethod('setDefaultAttributes'); - $attributes = $this->getPrivateProperty('attributes'); - - $this->assertEquals('default', $attributes['queue']); - $this->assertEquals('App\Jobs\TestJob', $attributes['action']); - $this->assertEquals('1', $attributes['attempts']); - $this->assertEquals('redis', $attributes['connection']); - $this->assertEquals('123', $attributes['job_id']); - } - - public function testSetException() - { - $this->profiler->setException('Test exception'); - $this->assertEquals('Test exception', $this->getPrivateProperty('attributes')['exception']); - } - - private function callPrivateMethod(string $methodName, array $args = []) - { - $method = $this->reflection->getMethod($methodName); - $method->setAccessible(true); - return $method->invokeArgs($this->profiler, $args); - } - - private function getPrivateProperty(string $propertyName) - { - $property = $this->reflection->getProperty($propertyName); - $property->setAccessible(true); - return $property->getValue($this->profiler); - } -} diff --git a/tests/UniversalProfilerTest.php b/tests/UniversalProfilerTest.php index 0da9a18..261c4bf 100644 --- a/tests/UniversalProfilerTest.php +++ b/tests/UniversalProfilerTest.php @@ -38,10 +38,8 @@ protected function setUp(): void 'api_key' => 'test-key', 'flags' => 0, 'sample_rate' => 1.0, - 'sending' => [ - 'proxy' => null, - 'timeout' => 5, - ], + 'proxy' => null, + 'timeout' => 5, ] ]); } From 1eb7a5e8896ac0a0f38f0680f90f3cca46ce202a Mon Sep 17 00:00:00 2001 From: Ben Poulson Date: Wed, 8 Apr 2026 15:05:40 +0700 Subject: [PATCH 2/2] v1.0.0 WIP --- README.md | 544 +++++++++++------------- composer.json | 2 +- config/perfbase.php | 12 +- phpunit.xml | 6 + src/Lifecycle/ConsoleTraceLifecycle.php | 33 ++ src/Lifecycle/HttpTraceLifecycle.php | 107 +++++ src/Lifecycle/QueueTraceLifecycle.php | 39 ++ src/Middleware/PerfbaseMiddleware.php | 39 +- src/PerfbaseServiceProvider.php | 232 ++++------ src/Profiling/AbstractProfiler.php | 27 +- src/Profiling/HttpProfiler.php | 226 ---------- src/Profiling/UniversalProfiler.php | 93 ---- src/Support/FilterMatcher.php | 76 ++++ tests/AbstractProfilerTest.php | 78 +++- tests/ConsoleTraceLifecycleTest.php | 167 ++++++++ tests/ErrorHandlingTest.php | 162 +++++++ tests/EventFlowTest.php | 284 +++++++++++++ tests/FilterMatcherConfigTest.php | 114 +++++ tests/HttpProfilerTest.php | 217 ---------- tests/HttpTraceLifecycleTest.php | 208 +++++++++ tests/MatchesFiltersTest.php | 32 +- tests/QueueTraceLifecycleTest.php | 160 +++++++ tests/UniversalProfilerTest.php | 373 ---------------- 23 files changed, 1782 insertions(+), 1449 deletions(-) create mode 100644 src/Lifecycle/ConsoleTraceLifecycle.php create mode 100644 src/Lifecycle/HttpTraceLifecycle.php create mode 100644 src/Lifecycle/QueueTraceLifecycle.php delete mode 100644 src/Profiling/HttpProfiler.php delete mode 100644 src/Profiling/UniversalProfiler.php create mode 100644 src/Support/FilterMatcher.php create mode 100644 tests/ConsoleTraceLifecycleTest.php create mode 100644 tests/ErrorHandlingTest.php create mode 100644 tests/EventFlowTest.php create mode 100644 tests/FilterMatcherConfigTest.php delete mode 100644 tests/HttpProfilerTest.php create mode 100644 tests/HttpTraceLifecycleTest.php create mode 100644 tests/QueueTraceLifecycleTest.php delete mode 100644 tests/UniversalProfilerTest.php diff --git a/README.md b/README.md index 42aaf94..9762720 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,62 @@ -# Perfbase for Laravel - -[![Packagist License](https://img.shields.io/packagist/l/perfbase/laravel)](https://github.com/perfbaseorg/laravel/blob/main/LICENSE.txt) -[![Packagist Version](https://img.shields.io/packagist/v/perfbase/laravel)](https://packagist.org/packages/perfbase/laravel) -[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/perfbaseorg/laravel/ci.yml?branch=main)](https://github.com/perfbaseorg/laravel/actions/workflows/ci.yml) - -Seamless Laravel integration for Perfbase - a comprehensive Application Performance Monitoring (APM) solution that provides real-time insights into your Laravel application's performance, database queries, HTTP requests, queue jobs, and more. - -## Features - -- 🚀 **Automatic Profiling** - HTTP requests, console commands, and queue jobs -- 📊 **Multi-span Tracing** - Track nested operations within requests -- 🔍 **Database Query Monitoring** - Monitor all database operations with timing -- 🌐 **HTTP Request Tracking** - Monitor outbound API calls and their performance -- ⚡ **Queue Job Profiling** - Track background job performance and failures -- 🏷️ **Custom Attributes** - Add contextual metadata to traces -- 🎯 **Smart Sampling** - Control data collection with configurable sample rates -- 💾 **Reliable Delivery** - Explicit success/failure reporting on trace submission -- 🔧 **Granular Control** - Include/exclude specific routes, commands, or jobs -- 🛡️ **Multi-tenant Support** - Organization and project-level data isolation +

+ + Perfbase + +

+ +

Perfbase for Laravel

+

+ Laravel integration for Perfbase. +

+ +

+ Packagist Version + License + CI + PHP Version + Laravel Version +

+ +This package is a thin adapter over [`perfbase/php-sdk`](https://packagist.org/packages/perfbase/php-sdk). It wires Laravel request, console, and queue lifecycles into the SDK and leaves trace transport, submission, and extension handling to the shared SDK. + +## What it profiles + +- HTTP requests when the Perfbase middleware is installed +- Artisan commands through Laravel console events +- Queue jobs through Laravel queue events +- Manual custom spans through the `Perfbase` facade or injected SDK client ## Requirements -- **PHP**: 7.4 to 8.4 -- **Laravel**: 8.0, 9.0, 10.0, 11.0, or 12.0 -- **Extensions**: - - `ext-json` (usually enabled by default) - - `ext-zlib` (usually enabled by default) - - `ext-perfbase` (Perfbase PHP extension) -- **Dependencies**: Guzzle HTTP 7.0+ +- PHP `7.4` to `8.5` +- Laravel `8.x`, `9.x`, `10.x`, `11.x`, or `12.x` +- `ext-json` +- `ext-zlib` +- `ext-perfbase` ## Installation -### 1. Install the Package +Install the package from Packagist: ```bash -composer require perfbase/laravel +composer require perfbase/laravel:^1.0 ``` -### 2. Install the Perfbase PHP Extension - -The `ext-perfbase` PHP extension is required. Install it using: +Install the native Perfbase extension if it is not already available: ```bash bash -c "$(curl -fsSL https://cdn.perfbase.com/install.sh)" ``` -**Important**: Restart your web server after installation. +Restart PHP-FPM, Octane workers, Horizon workers, or your web server after installing the extension. -### 3. Publish Configuration +Publish the config file: ```bash php artisan vendor:publish --tag="perfbase-config" ``` -This creates `config/perfbase.php` with all available options. - -### 4. Configure Environment - -Add to your `.env` file: +Add the minimum environment variables: ```env PERFBASE_ENABLED=true @@ -65,38 +64,51 @@ PERFBASE_API_KEY=your_api_key_here PERFBASE_SAMPLE_RATE=0.1 ``` -### 5. Add Middleware (Optional but Recommended) +### HTTP middleware -For HTTP request profiling, add the middleware to your HTTP kernel: +HTTP profiling is enabled only when the middleware is present. + +For Laravel 8 to 10, add it to `app/Http/Kernel.php`: ```php -// app/Http/Kernel.php protected $middleware = [ - // ... other middleware + // ... \Perfbase\Laravel\Middleware\PerfbaseMiddleware::class, ]; ``` -Or apply to specific route groups: +Or attach it to a middleware group: ```php -// app/Http/Kernel.php protected $middlewareGroups = [ 'web' => [ - // ... other middleware + // ... \Perfbase\Laravel\Middleware\PerfbaseMiddleware::class, ], ]; ``` -## Configuration +For Laravel 11+, register it in `bootstrap/app.php`: -### Basic Configuration +```php +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Configuration\Middleware; +use Perfbase\Laravel\Middleware\PerfbaseMiddleware; + +return Application::configure(dirname(__DIR__)) + ->withMiddleware(function (Middleware $middleware) { + $middleware->append(PerfbaseMiddleware::class); + }) + ->create(); +``` + +Console and queue profiling do not need middleware. They are wired through the package service provider. + +## Configuration -The package auto-registers and provides several configuration options: +Published config lives at `config/perfbase.php`. ```php -// config/perfbase.php return [ 'enabled' => env('PERFBASE_ENABLED', false), 'debug' => env('PERFBASE_DEBUG', false), @@ -106,161 +118,214 @@ return [ 'timeout' => env('PERFBASE_TIMEOUT', 5), 'proxy' => env('PERFBASE_PROXY'), 'flags' => env('PERFBASE_FLAGS', \Perfbase\SDK\FeatureFlags::DefaultFlags), - // ... include/exclude filters + 'include' => [ + 'http' => ['.*'], + 'console' => ['.*'], + 'queue' => ['.*'], + ], + 'exclude' => [ + 'http' => [], + 'console' => ['queue:work'], + 'queue' => [], + ], ]; ``` -### Environment Variables +### Environment variables -| Variable | Default | Description | -|----------|---------|-------------| -| `PERFBASE_ENABLED` | `false` | Enable/disable profiling | -| `PERFBASE_API_KEY` | `null` | Your Perfbase API key (required) | -| `PERFBASE_SAMPLE_RATE` | `0.1` | Sampling rate (0.0 to 1.0) | -| `PERFBASE_DEBUG` | `false` | Enable debug mode (throws exceptions) | -| `PERFBASE_LOG_ERRORS` | `true` | Log profiling errors | -| `PERFBASE_TIMEOUT` | `5` | API request timeout in seconds | -| `PERFBASE_PROXY` | `null` | HTTP proxy URL | -| `PERFBASE_FLAGS` | Default flags | Profiling feature flags | +| Variable | Default | Purpose | +| --- | --- | --- | +| `PERFBASE_ENABLED` | `false` | Global on/off switch | +| `PERFBASE_API_KEY` | `null` | Perfbase API key | +| `PERFBASE_SAMPLE_RATE` | `0.1` | Sampling rate from `0.0` to `1.0` | +| `PERFBASE_DEBUG` | `false` | Re-throw profiling exceptions | +| `PERFBASE_LOG_ERRORS` | `true` | Log profiling failures when debug is off | +| `PERFBASE_TIMEOUT` | `5` | Trace submission timeout in seconds | +| `PERFBASE_PROXY` | `null` | Optional outbound proxy | +| `PERFBASE_FLAGS` | `FeatureFlags::DefaultFlags` | Perfbase extension feature flags | -### Profiling Features Control - -Control which profiling features are enabled: +### Feature flags ```php use Perfbase\SDK\FeatureFlags; -// In config/perfbase.php -'flags' => FeatureFlags::DefaultFlags, // Recommended for most apps -'flags' => FeatureFlags::AllFlags, // All available features -'flags' => FeatureFlags::TrackCpuTime | FeatureFlags::TrackPdo, // Custom combination +'flags' => FeatureFlags::DefaultFlags; +'flags' => FeatureFlags::AllFlags; +'flags' => FeatureFlags::TrackCpuTime | FeatureFlags::TrackPdo; ``` -Available flags: -- `UseCoarseClock` - Faster timing (reduced overhead) -- `TrackCpuTime` - Monitor CPU time usage -- `TrackMemoryAllocation` - Track memory allocation patterns -- `TrackPdo` - Monitor database queries -- `TrackHttp` - Track outbound HTTP requests -- `TrackCaches` - Monitor cache operations -- `TrackMongodb` - Track MongoDB operations -- `TrackElasticsearch` - Monitor Elasticsearch queries -- `TrackQueues` - Track queue/background jobs -- `TrackAwsSdk` - Monitor AWS SDK operations -- `TrackFileOperations` - Track file I/O operations +Common flags: + +- `UseCoarseClock` +- `TrackCpuTime` +- `TrackMemoryAllocation` +- `TrackPdo` +- `TrackHttp` +- `TrackCaches` +- `TrackMongodb` +- `TrackElasticsearch` +- `TrackQueues` +- `TrackAwsSdk` +- `TrackFileOperations` +- `TrackFileCompilation` +- `TrackFileDefinitions` +- `TrackExceptions` -### Include/Exclude Filters +### Include and exclude filters -Control which routes, commands, and jobs are profiled: +Filters are split by context: `http`, `console`, and `queue`. ```php -// config/perfbase.php 'include' => [ - 'http' => [ - 'api/*', - 'admin/*' - ], - 'artisan' => [ - 'app:*', - 'queue:*' - ], - 'jobs' => [ - 'App\\Jobs\\*' - ] + 'http' => ['GET /api/*', 'POST /checkout'], + 'console' => ['migrate*', 'app:*'], + 'queue' => ['App\\Jobs\\Important*'], ], 'exclude' => [ - 'http' => [ - 'health-check', - '_debugbar/*' - ], - 'artisan' => [ - 'horizon:*', - 'telescope:*' - ], - 'jobs' => [ - 'App\\Jobs\\DebugJob' - ] -] + 'http' => ['GET /health*', '_debugbar/*'], + 'console' => ['queue:work', 'horizon:*'], + 'queue' => ['App\\Jobs\\NoisyDebugJob'], +], ``` -## Usage +Supported filter styles: + +- Wildcards like `GET /api/*` +- Regex patterns like `/^POST \/checkout/` +- Command patterns like `queue:*` +- Job class patterns like `App\\Jobs\\*` +- Controller or action strings matched through Laravel's string matcher + +## How it behaves + +### HTTP requests + +`PerfbaseMiddleware` creates an `HttpTraceLifecycle` for the current request. -### Automatic Profiling +Recorded attributes include: -Once configured, Perfbase automatically profiles: +- `source=http` +- `action` +- `http_method` +- `http_url` +- `http_status_code` +- `user_ip` +- `user_agent` +- `user_id` when available +- `environment` +- `app_version` +- `hostname` +- `php_version` -- **HTTP Requests** (when middleware is added) -- **Console Commands** (all artisan commands) -- **Queue Jobs** (all queued jobs) +### Console commands -### Manual Profiling +The service provider listens to Laravel console events and creates a `ConsoleTraceLifecycle`. -Use the facade for custom profiling: +Recorded attributes include: + +- `source=console` +- `action` +- `exit_code` +- `exception` when present +- `environment` +- `app_version` +- `hostname` +- `php_version` + +### Queue jobs + +The service provider listens to queue worker events and creates a `QueueTraceLifecycle`. + +Recorded attributes include: + +- `source=queue` +- `action` +- `queue` +- `connection` +- `exception` when present +- `environment` +- `app_version` +- `hostname` +- `php_version` + +## Manual spans + +Use the facade when you want custom spans inside your own application code: ```php use Perfbase\Laravel\Facades\Perfbase; -// Start a custom span Perfbase::startTraceSpan('custom-operation', [ 'operation_type' => 'data_processing', - 'record_count' => '1000' + 'record_count' => '1000', ]); -// Add attributes during execution Perfbase::setAttribute('processing_method', 'batch'); -Perfbase::setAttribute('memory_usage', memory_get_usage()); +Perfbase::setAttribute('memory_usage', (string) memory_get_usage()); try { - // Your custom logic here processLargeDataset(); - Perfbase::setAttribute('status', 'success'); -} catch (Exception $e) { +} catch (\Exception $e) { Perfbase::setAttribute('status', 'error'); Perfbase::setAttribute('error_message', $e->getMessage()); + throw $e; } finally { - // Always stop the span Perfbase::stopTraceSpan('custom-operation'); } -// Submit the trace data -Perfbase::submitTrace(); +$result = Perfbase::submitTrace(); + +if (!$result->isSuccess()) { + logger()->warning('Perfbase trace submission failed', [ + 'status' => $result->getStatus(), + 'message' => $result->getMessage(), + 'status_code' => $result->getStatusCode(), + ]); +} ``` -### Service Injection +Note that Perfbase trace attributes are string values. Cast integers and booleans before passing them to `setAttribute()`. -Use dependency injection in your services: +## Dependency injection + +You can inject the SDK client directly: ```php use Perfbase\SDK\Perfbase; class DataProcessingService { - public function __construct(private Perfbase $perfbase) + /** @var Perfbase */ + private $perfbase; + + public function __construct(Perfbase $perfbase) { + $this->perfbase = $perfbase; } - + public function processData(array $data): array { $this->perfbase->startTraceSpan('data-processing', [ - 'record_count' => count($data), - 'data_type' => 'user_records' + 'record_count' => (string) count($data), + 'data_type' => 'user_records', ]); - - $result = $this->performProcessing($data); - - $this->perfbase->setAttribute('processed_count', count($result)); - $this->perfbase->stopTraceSpan('data-processing'); - - return $result; + + try { + $result = $this->performProcessing($data); + $this->perfbase->setAttribute('processed_count', (string) count($result)); + return $result; + } finally { + $this->perfbase->stopTraceSpan('data-processing'); + } } } ``` -### User-Specific Profiling +## User-specific request profiling -Profile specific users by implementing the `ProfiledUser` interface: +If your authenticated user model implements `Perfbase\Laravel\Interfaces\ProfiledUser`, HTTP request profiling will respect `shouldBeProfiled()`. ```php use Perfbase\Laravel\Interfaces\ProfiledUser; @@ -269,204 +334,79 @@ class User extends Authenticatable implements ProfiledUser { public function shouldBeProfiled(): bool { - // Profile admin users or users in beta testing return $this->isAdmin() || $this->isBetaTester(); } } ``` -## Advanced Configuration - -### Performance Optimization - -For high-traffic applications: - -```php -// config/perfbase.php -'sample_rate' => 0.01, // Profile 1% of requests -'flags' => \Perfbase\SDK\FeatureFlags::UseCoarseClock | - \Perfbase\SDK\FeatureFlags::TrackCpuTime | - \Perfbase\SDK\FeatureFlags::TrackPdo, -``` +If the authenticated user does not implement `ProfiledUser`, the package falls back to normal request filtering rules. -### Multi-Environment Setup - -```php -// config/perfbase.php -'enabled' => env('PERFBASE_ENABLED', app()->environment('production')), -'sample_rate' => env('PERFBASE_SAMPLE_RATE', match(app()->environment()) { - 'production' => 0.1, - 'staging' => 0.5, - 'local' => 1.0, - default => 0.1 -}), -``` - -## Facade Methods - -The Perfbase facade provides access to all SDK methods: +## Facade methods | Method | Description | -|--------|-------------| -| `startTraceSpan($name, $attributes = [])` | Start profiling a named span | -| `stopTraceSpan($name)` | Stop profiling a named span | -| `setAttribute($key, $value)` | Add attribute to current trace | -| `setFlags($flags)` | Change profiling feature flags | -| `submitTrace()` | Submit trace data to Perfbase (returns `SubmitResult`) | +| --- | --- | +| `startTraceSpan($name, $attributes = [])` | Start a named span | +| `stopTraceSpan($name)` | Stop a named span | +| `setAttribute($key, $value)` | Add a string attribute to the current trace | +| `setFlags($flags)` | Change extension feature flags | +| `submitTrace()` | Submit trace data and return a `SubmitResult` | | `getTraceData($spanName = '')` | Get raw trace data | -| `reset()` | Clear current trace session | -| `isExtensionAvailable()` | Check if extension is loaded | +| `reset()` | Clear the current trace session | +| `isExtensionAvailable()` | Check whether the native extension is loaded | -## Error Handling +## Error handling -The package handles errors gracefully: +The package is designed to fail open in normal operation. When profiling cannot start or trace submission fails, your Laravel request, command, or job should continue running. -```php -// The package won't break your app if Perfbase is unavailable -try { - Perfbase::startTraceSpan('critical-operation'); - // Your code here -} catch (\Perfbase\SDK\Exception\PerfbaseExtensionException $e) { - // Extension not available - log but continue - Log::warning('Perfbase extension not available: ' . $e->getMessage()); -} -``` - -## Troubleshooting - -### Extension Not Found - -```bash -# Check if extension is loaded -php -m | grep perfbase - -# Check PHP configuration -php --ini +Use `PERFBASE_DEBUG=true` if you want profiling exceptions to surface during local development. -# Reinstall extension -bash -c "$(curl -fsSL https://cdn.perfbase.com/install.sh)" -``` +## Testing -### High Memory Usage +In application tests, it is often simplest to disable profiling: -```php -// Reduce profiling overhead -'flags' => \Perfbase\SDK\FeatureFlags::UseCoarseClock | - \Perfbase\SDK\FeatureFlags::TrackCpuTime, -'sample_rate' => 0.01, // Lower sample rate +```xml + ``` -## Testing - -When testing your Laravel application: +You can also mock the facade: ```php -// Disable Perfbase in tests -// phpunit.xml - +use Perfbase\Laravel\Facades\Perfbase; -// Or mock the facade in tests public function test_something() { Perfbase::shouldReceive('startTraceSpan')->once(); Perfbase::shouldReceive('stopTraceSpan')->once(); - - // Your test code + + // ... } ``` -## Performance Impact - -- **Minimal Overhead**: ~1-3ms per request with default settings -- **Sampling**: Use sample rates to reduce impact in production -- **Selective Profiling**: Use include/exclude filters strategically - -## Security Considerations - -- **API Key Security**: Store API keys in environment variables, not code -- **Data Privacy**: Configure include/exclude filters to avoid sensitive routes -- **User Profiling**: Implement `ProfiledUser` interface to control user-specific profiling -- **Network Security**: Use HTTPS endpoints and configure proxy if needed - -## Examples +## Troubleshooting -### E-commerce Checkout +### Extension not loaded -```php -class CheckoutController extends Controller -{ - public function process(Request $request) - { - Perfbase::startTraceSpan('checkout-process', [ - 'user_id' => auth()->id(), - 'cart_items' => $request->items->count(), - 'payment_method' => $request->payment_method - ]); - - try { - $order = $this->createOrder($request); - $payment = $this->processPayment($order); - - Perfbase::setAttribute('order_id', $order->id); - Perfbase::setAttribute('payment_status', $payment->status); - - return response()->json(['order' => $order]); - } finally { - Perfbase::stopTraceSpan('checkout-process'); - } - } -} +```bash +php -m | grep perfbase +php --ini +bash -c "$(curl -fsSL https://cdn.perfbase.com/install.sh)" ``` -### Background Job Processing +### High overhead -```php -class ProcessEmailCampaignJob implements ShouldQueue -{ - public function handle() - { - // Automatic profiling happens via queue listener - // But you can add custom spans for detailed tracking - - Perfbase::startTraceSpan('email-template-render'); - $template = $this->renderTemplate(); - Perfbase::stopTraceSpan('email-template-render'); - - Perfbase::startTraceSpan('email-send-batch'); - $this->sendEmails($template); - Perfbase::stopTraceSpan('email-send-batch'); - } -} -``` +- Lower `PERFBASE_SAMPLE_RATE` +- Use `FeatureFlags::UseCoarseClock` +- Disable feature flags you do not need +- Narrow your `include` filters and expand your `exclude` filters ## Documentation -Comprehensive documentation is available at [https://docs.perfbase.com](https://docs.perfbase.com), including: - -- Complete API reference -- Framework-specific guides -- Performance optimization tips -- Data privacy and security policies -- Troubleshooting guides - -## Contributing +Full documentation is available at [perfbase.com/docs](https://perfbase.com/docs). -We welcome contributions! Please see our [contributing guidelines](CONTRIBUTING.md) and feel free to submit pull requests. - -## Security - -If you discover any security-related issues, please email [security@perfbase.com](mailto:security@perfbase.com) instead of using the issue tracker. - -## Support - -- **Email**: [support@perfbase.com](mailto:support@perfbase.com) -- **Documentation**: [https://docs.perfbase.com](https://docs.perfbase.com) -- **Issues**: [GitHub Issues](https://github.com/perfbaseorg/laravel/issues) +- **Docs**: [perfbase.com/docs](https://perfbase.com/docs) +- **Issues**: [github.com/perfbaseorg/laravel/issues](https://github.com/perfbaseorg/laravel/issues) +- **Support**: [support@perfbase.com](mailto:support@perfbase.com) ## License -This project is licensed under the Apache License 2.0. Please see the [License File](LICENSE.txt) for more information. - ---- - -**Made with ❤️ by the Perfbase team** \ No newline at end of file +Apache-2.0. See [LICENSE.txt](LICENSE.txt). diff --git a/composer.json b/composer.json index 5999492..bbc5dc8 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "ext-json": "*", "ext-zlib": "*", "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", - "perfbase/php-sdk": "^0.3.0", + "perfbase/php-sdk": "1.0.0", "guzzlehttp/guzzle": "^7.0" }, "require-dev": { diff --git a/config/perfbase.php b/config/perfbase.php index 258c8f1..d90abe7 100644 --- a/config/perfbase.php +++ b/config/perfbase.php @@ -155,10 +155,8 @@ */ 'include' => [ 'http' => ['.*'], - 'artisan' => ['.*'], - 'jobs' => ['.*'], - 'schedule' => ['.*'], - 'exception' => ['.*'], + 'console' => ['.*'], + 'queue' => ['.*'], ], /* @@ -172,10 +170,8 @@ */ 'exclude' => [ 'http' => [], - 'artisan' => ['queue:work'], - 'jobs' => [], - 'schedule' => [], - 'exception' => [], + 'console' => ['queue:work'], + 'queue' => [], ], ]; diff --git a/phpunit.xml b/phpunit.xml index 92e2786..2f8b4cf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,6 +8,12 @@ + + + src + + + diff --git a/src/Lifecycle/ConsoleTraceLifecycle.php b/src/Lifecycle/ConsoleTraceLifecycle.php new file mode 100644 index 0000000..b496974 --- /dev/null +++ b/src/Lifecycle/ConsoleTraceLifecycle.php @@ -0,0 +1,33 @@ +command = $command; + } + + protected function shouldProfile(): bool + { + return FilterMatcher::passesConfigFilters([$this->command], 'console'); + } + + protected function setDefaultAttributes(): void + { + parent::setDefaultAttributes(); + + $this->setAttributes([ + 'source' => 'console', + 'action' => $this->command, + ]); + } +} diff --git a/src/Lifecycle/HttpTraceLifecycle.php b/src/Lifecycle/HttpTraceLifecycle.php new file mode 100644 index 0000000..c8ebdac --- /dev/null +++ b/src/Lifecycle/HttpTraceLifecycle.php @@ -0,0 +1,107 @@ +request = $request; + } + + public function setResponse(Response $response): void + { + $this->setAttribute('http_status_code', (string) $response->getStatusCode()); + } + + protected function shouldProfile(): bool + { + if (!config('perfbase.enabled', false)) { + return false; + } + + /** @var Authenticatable|null $user */ + $user = $this->request->user(); + if ($user instanceof ProfiledUser && !$user->shouldBeProfiled()) { + return false; + } + + $components = $this->getRequestComponents(); + if (!FilterMatcher::passesConfigFilters($components, 'http')) { + return false; + } + + if (!$this->perfbase->isExtensionAvailable()) { + return false; + } + + return true; + } + + protected function setDefaultAttributes(): void + { + parent::setDefaultAttributes(); + + $route = $this->request->route(); + $action = $route instanceof Route + ? sprintf('%s %s', $this->request->method(), $route->uri()) + : sprintf('%s %s', $this->request->method(), $this->request->path()); + + $this->setAttributes([ + 'source' => 'http', + 'action' => $action, + 'http_method' => $this->request->method(), + 'http_url' => $this->request->fullUrl(), + 'user_ip' => EnvironmentUtils::getUserIp() ?? '', + 'user_agent' => EnvironmentUtils::getUserUserAgent() ?? '', + ]); + + if (Auth::check()) { + $this->setAttribute('user_id', (string) Auth::id()); + } + } + + /** + * @return array + */ + private function getRequestComponents(): array + { + $pathWithSlash = '/' . ltrim($this->request->path(), '/'); + $components = [ + sprintf('%s %s', $this->request->method(), $pathWithSlash), + sprintf('%s %s', $this->request->method(), $this->request->path()), + $this->request->path(), + $pathWithSlash, + ]; + + $route = $this->request->route(); + if ($route instanceof Route) { + $explodedAction = explode('@', $route->getActionName()); + $components[] = $route->getActionName(); + $components[] = $route->uri(); + $components[] = '/' . ltrim($route->uri(), '/'); + $components[] = $explodedAction[0]; + + foreach ($route->methods() as $method) { + $components[] = sprintf('%s %s', $method, $route->uri()); + $components[] = sprintf('%s %s', $method, '/' . ltrim($route->uri(), '/')); + } + } + + return $components; + } +} diff --git a/src/Lifecycle/QueueTraceLifecycle.php b/src/Lifecycle/QueueTraceLifecycle.php new file mode 100644 index 0000000..371ea39 --- /dev/null +++ b/src/Lifecycle/QueueTraceLifecycle.php @@ -0,0 +1,39 @@ +jobName = $jobName; + $this->queue = $queue; + $this->connection = $connection; + } + + protected function shouldProfile(): bool + { + return FilterMatcher::passesConfigFilters([$this->jobName], 'queue'); + } + + protected function setDefaultAttributes(): void + { + parent::setDefaultAttributes(); + + $this->setAttributes([ + 'source' => 'queue', + 'action' => $this->jobName, + 'queue' => $this->queue, + 'connection' => $this->connection, + ]); + } +} diff --git a/src/Middleware/PerfbaseMiddleware.php b/src/Middleware/PerfbaseMiddleware.php index 2e0795c..13d6b09 100644 --- a/src/Middleware/PerfbaseMiddleware.php +++ b/src/Middleware/PerfbaseMiddleware.php @@ -3,50 +3,27 @@ namespace Perfbase\Laravel\Middleware; use Closure; -use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Http\Request; -use JsonException; -use Perfbase\Laravel\Profiling\HttpProfiler; -use Perfbase\SDK\Exception\PerfbaseException; -use Perfbase\SDK\Exception\PerfbaseExtensionException; -use Perfbase\SDK\Exception\PerfbaseInvalidSpanException; +use Perfbase\Laravel\Lifecycle\HttpTraceLifecycle; use Symfony\Component\HttpFoundation\Response; -/** - * Class PerfbaseMiddleware - * - * Middleware to handle request profiling using Perfbase. - */ class PerfbaseMiddleware { - /** - * Handle the incoming request. - * - * @param Request $request - * @param Closure $next - * @return Response - * @throws BindingResolutionException - * @throws JsonException - * @throws PerfbaseException - * @throws PerfbaseExtensionException - * @throws PerfbaseInvalidSpanException - */ public function handle(Request $request, Closure $next): Response { - // Check if profiling is enabled if (!config('perfbase.enabled')) { - // No profiling enabled, just pass the request return $next($request); } - // Profiler is enabled, start profiling - $profiler = new HttpProfiler($request); - $profiler->startProfiling(); + $lifecycle = new HttpTraceLifecycle($request); + $lifecycle->startProfiling(); + /** @var Response $response */ $response = $next($request); - $profiler->setResponse($response); - $profiler->stopProfiling(); - return $response; + $lifecycle->setResponse($response); + $lifecycle->stopProfiling(); + + return $response; } } diff --git a/src/PerfbaseServiceProvider.php b/src/PerfbaseServiceProvider.php index 23871fe..daa2050 100644 --- a/src/PerfbaseServiceProvider.php +++ b/src/PerfbaseServiceProvider.php @@ -11,34 +11,40 @@ use Illuminate\Queue\Events\JobProcessing; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Perfbase\Laravel\Lifecycle\ConsoleTraceLifecycle; +use Perfbase\Laravel\Lifecycle\QueueTraceLifecycle; use Perfbase\Laravel\Profiling\AbstractProfiler; -use Perfbase\Laravel\Profiling\UniversalProfiler; use Perfbase\Laravel\Support\PerfbaseConfig; use Perfbase\Laravel\Support\PerfbaseErrorHandling; -use Perfbase\Laravel\Support\SpanNaming; use Perfbase\SDK\Config; use Perfbase\SDK\Config as SdkConfig; use Perfbase\SDK\Perfbase; use Perfbase\SDK\Perfbase as PerfbaseClient; use Perfbase\SDK\Extension\ExtensionInterface; -/** - * Class PerfbaseServiceProvider - */ class PerfbaseServiceProvider extends ServiceProvider { use PerfbaseErrorHandling; /** - * Unified span storage with unique IDs - * @var array + * Active profiler instances keyed by span ID. + * @var array */ private array $spans = []; /** - * @return void + * Queue job span IDs keyed by job ID. + * @var array */ - public function boot() + private array $queueSpanIds = []; + + /** + * Console command span IDs keyed by command name. + * @var array + */ + private array $consoleSpanIds = []; + + public function boot(): void { if ($this->app->runningInConsole()) { $this->publishes([ @@ -51,42 +57,19 @@ public function boot() } } - /** - * Register the application services. - * @return void - */ - public function register() + public function register(): void { - // Register the config $this->mergeConfigFrom(__DIR__ . '/../config/perfbase.php', 'perfbase'); - /** - * Bind the Config class to the container - */ $this->app->bind(Config::class, function (Application $app) { - - /** - * @var array $config - */ + /** @var array $config */ $config = $app['config']; - /** @var int $flags */ - $flags = $config['perfbase.flags']; - - /** @var string|null $proxy */ - $proxy = $config['perfbase.proxy']; - - /** @var numeric $timeout */ - $timeout = $config['perfbase.timeout']; - - /** @var string $apiKey */ - $apiKey = $config['perfbase.api_key']; - return Config::fromArray([ - 'api_key' => $apiKey, - 'flags' => $flags, - 'proxy' => $proxy, - 'timeout' => $timeout, + 'api_key' => $config['perfbase.api_key'], + 'flags' => $config['perfbase.flags'], + 'proxy' => $config['perfbase.proxy'], + 'timeout' => $config['perfbase.timeout'], ]); }); @@ -94,73 +77,61 @@ public function register() /** @var SdkConfig $config */ $config = $app->make(SdkConfig::class); - // Check if we have a mocked extension in the container (for testing) - $extension = $app->bound(ExtensionInterface::class) - ? $app->make(ExtensionInterface::class) + $extension = $app->bound(ExtensionInterface::class) + ? $app->make(ExtensionInterface::class) : null; - // Start a new perfbase instance return new PerfbaseClient($config, $extension); }); } - /** - * Register unified event listeners for profiling - * - * @return void - */ private function registerEventListeners(): void { + // Queue: JobProcessing → start, JobProcessed/JobExceptionOccurred → stop $this->registerEventPair( JobProcessing::class, JobProcessed::class, JobExceptionOccurred::class, - fn($event) => $this->createQueueProfiler($event) + fn($event) => $this->createQueueLifecycle($event) ); + // Console: CommandStarting → start, CommandFinished → stop $this->registerEventPair( CommandStarting::class, CommandFinished::class, null, - fn($event) => $this->createConsoleProfiler($event) + fn($event) => $this->createConsoleLifecycle($event) ); } - /** - * Register a pair of start/stop/error events with unified handling - * - * @param string $startEvent - * @param string $stopEvent - * @param string|null $errorEvent - * @param callable $profilerFactory - * @return void - */ - private function registerEventPair(string $startEvent, string $stopEvent, ?string $errorEvent, callable $profilerFactory): void - { - // Start event handler - Event::listen($startEvent, function ($event) use ($profilerFactory) { + private function registerEventPair( + string $startEvent, + string $stopEvent, + ?string $errorEvent, + callable $lifecycleFactory + ): void { + Event::listen($startEvent, function ($event) use ($lifecycleFactory) { try { $spanId = uniqid('span_', true); - $profiler = $profilerFactory($event); - - if ($profiler) { - $this->spans[$spanId] = $profiler; + $lifecycle = $lifecycleFactory($event); + + if ($lifecycle) { + $this->spans[$spanId] = $lifecycle; $this->storeSpanId($event, $spanId); - $profiler->startProfiling(); + $lifecycle->startProfiling(); } } catch (\Throwable $e) { $this->handleProfilingError($e, 'event_start'); } }); - // Stop event handler Event::listen($stopEvent, function ($event) { try { $spanId = $this->getSpanId($event); if ($spanId && isset($this->spans[$spanId])) { - $profiler = $this->spans[$spanId]; - $this->handleEventData($event, $profiler); - $profiler->stopProfiling(); + $lifecycle = $this->spans[$spanId]; + $this->handleEventData($event, $lifecycle); + $lifecycle->stopProfiling(); unset($this->spans[$spanId]); } } catch (\Throwable $e) { @@ -168,15 +139,14 @@ private function registerEventPair(string $startEvent, string $stopEvent, ?strin } }); - // Error event handler (if provided) if ($errorEvent) { Event::listen($errorEvent, function ($event) { try { $spanId = $this->getSpanId($event); if ($spanId && isset($this->spans[$spanId])) { - $profiler = $this->spans[$spanId]; - $this->handleEventData($event, $profiler); - $profiler->stopProfiling(); + $lifecycle = $this->spans[$spanId]; + $this->handleEventData($event, $lifecycle); + $lifecycle->stopProfiling(); unset($this->spans[$spanId]); } } catch (\Throwable $e) { @@ -186,119 +156,80 @@ private function registerEventPair(string $startEvent, string $stopEvent, ?strin } } - /** - * Create a queue profiler from event - * - * @param JobProcessing $event - * @return UniversalProfiler - */ - private function createQueueProfiler(JobProcessing $event): UniversalProfiler + private function createQueueLifecycle(JobProcessing $event): QueueTraceLifecycle { - $jobName = $this->getCommandFromJob($event->job); - $spanName = SpanNaming::forQueue($jobName); - - return new UniversalProfiler($spanName, [ - 'job_name' => $jobName, - 'queue' => $event->job->getQueue(), - 'connection' => $event->connectionName, - ]); + $jobName = $this->getJobDisplayName($event->job); + + return new QueueTraceLifecycle( + $jobName, + $event->job->getQueue(), + $event->connectionName + ); } - /** - * Create a console profiler from event - * - * @param CommandStarting $event - * @return UniversalProfiler|null - */ - private function createConsoleProfiler(CommandStarting $event): ?UniversalProfiler + private function createConsoleLifecycle(CommandStarting $event): ?ConsoleTraceLifecycle { - if (!$event->command || !$event->input || !$event->output) { + if (!$event->command) { return null; } - $spanName = SpanNaming::forConsole($event->command); - - return new UniversalProfiler($spanName, [ - 'command' => $event->command, - 'arguments' => $event->input->getArguments(), - 'options' => $event->input->getOptions(), - ]); + return new ConsoleTraceLifecycle($event->command); } - /** - * Store span ID for later retrieval - * - * @param mixed $event - * @param string $spanId - * @return void - */ - private function storeSpanId($event, string $spanId): void + /** @param JobProcessing|CommandStarting $event */ + private function storeSpanId(object $event, string $spanId): void { if ($event instanceof JobProcessing) { - // Queue job - store in payload - $payload = $event->job->payload(); - $payload['perfbase_span_id'] = $spanId; - } else { - // Console command - store as property - $event->perfbaseSpanId = $spanId; + $jobId = $event->job->getJobId() ?? spl_object_hash($event->job); + $this->queueSpanIds[$jobId] = $spanId; + } elseif ($event instanceof CommandStarting && $event->command) { + $this->consoleSpanIds[$event->command] = $spanId; } } - /** - * Get span ID from event - * - * @param mixed $event - * @return string|null - */ - private function getSpanId($event): ?string + /** @param JobProcessed|JobExceptionOccurred|CommandFinished $event */ + private function getSpanId(object $event): ?string { if ($event instanceof JobProcessed || $event instanceof JobExceptionOccurred) { - return $event->job->payload()['perfbase_span_id'] ?? null; + $jobId = $event->job->getJobId() ?? spl_object_hash($event->job); + $spanId = $this->queueSpanIds[$jobId] ?? null; + unset($this->queueSpanIds[$jobId]); + return $spanId; + } + + if ($event instanceof CommandFinished && $event->command) { + $spanId = $this->consoleSpanIds[$event->command] ?? null; + unset($this->consoleSpanIds[$event->command]); + return $spanId; } - - return $event->perfbaseSpanId ?? null; + + return null; } - /** - * Handle event-specific data - * - * @param mixed $event - * @param AbstractProfiler $profiler - * @return void - */ - private function handleEventData($event, AbstractProfiler $profiler): void + /** @param JobProcessed|JobExceptionOccurred|CommandFinished $event */ + private function handleEventData(object $event, AbstractProfiler $lifecycle): void { if ($event instanceof JobExceptionOccurred) { - $profiler->setException($event->exception->getMessage()); + $lifecycle->setException($event->exception->getMessage()); } elseif ($event instanceof CommandFinished) { - $profiler->setExitCode($event->exitCode); + $lifecycle->setExitCode($event->exitCode); } } - - /** - * Get the command name from the job. - * @param Job $job - * @return string - */ - private function getCommandFromJob(Job $job): string + private function getJobDisplayName(Job $job): string { $payload = $job->payload(); - // Try to get the display name first as it's the most reliable if (isset($payload['displayName'])) { return $payload['displayName']; } - // Try to get the command name from data if (isset($payload['data']['commandName'])) { return $payload['data']['commandName']; } - // Try to unserialize the command if it's a serialized object if (isset($payload['data']['command'])) { $command = $payload['data']['command']; - // Check if it's a serialized object (starts with O: or a:) if (is_string($command) && preg_match('/^[Oa]:\d+:/', $command)) { try { $unserialized = unserialize($command); @@ -311,7 +242,6 @@ private function getCommandFromJob(Job $job): string } } - // Fallback to the job class if (isset($payload['job'])) { return $payload['job']; } diff --git a/src/Profiling/AbstractProfiler.php b/src/Profiling/AbstractProfiler.php index 0ac8cce..0a19a19 100644 --- a/src/Profiling/AbstractProfiler.php +++ b/src/Profiling/AbstractProfiler.php @@ -8,7 +8,6 @@ use Perfbase\SDK\Exception\PerfbaseExtensionException; use Perfbase\SDK\Exception\PerfbaseInvalidSpanException; use Perfbase\SDK\Perfbase as PerfbaseClient; -use Perfbase\SDK\Utils\EnvironmentUtils; use RuntimeException; abstract class AbstractProfiler @@ -121,39 +120,21 @@ public function setAttributes(array $attributes): void } /** - * Set default attributes that should be included in every trace - * - * @throws PerfbaseException + * Set default attributes that should be included in every trace. + * Subclasses should call parent and add context-specific attributes. */ protected function setDefaultAttributes(): void { $environment = config('app.env', ''); - if (!is_string($environment)) { - throw new PerfbaseException('Config perfbase `app.env` must be a string.'); - } - $appVersion = config('app.version', ''); - if (!is_string($appVersion)) { - throw new PerfbaseException('Config `app.version` must be a string.'); - } - - $hostname = gethostname(); - if (!is_string($hostname)) { - $hostname = ''; - } - - $phpVersion = phpversion(); - if (!is_string($phpVersion)) { - $phpVersion = ''; - } + $hostname = gethostname() ?: ''; + $phpVersion = phpversion() ?: ''; $this->setAttributes([ 'hostname' => $hostname, 'environment' => $environment, 'app_version' => $appVersion, 'php_version' => $phpVersion, - 'user_ip' => EnvironmentUtils::getUserIp() ?? '', - 'user_agent' => EnvironmentUtils::getUserUserAgent() ?? '', ]); } diff --git a/src/Profiling/HttpProfiler.php b/src/Profiling/HttpProfiler.php deleted file mode 100644 index d3d2ede..0000000 --- a/src/Profiling/HttpProfiler.php +++ /dev/null @@ -1,226 +0,0 @@ -request = $request; - } - - public function setResponse(Response $response): void - { - $this->setAttribute('http_status_code', (string)$response->getStatusCode()); - } - - /** - * Determine if the current request should be profiled. - * This is determined by four factors: - * 1. Whether HTTP profiling is enabled in the configuration. - * 2. Whether the user should be profiled (if applicable). - * 3. Whether the requested route matches the include and exclude filters. - * 4. Whether the Perfbase extension is loaded. - * - * @return bool - */ - protected function shouldProfile(): bool - { - if (!config('perfbase.enabled', false)) { - return false; - } - - - /** @var Authenticatable $user */ - $user = $this->request->user(); - - // Check if the user should be profiled - if (!$this->shouldUserBeProfiled($user)) { - return false; - } - - // Get request components and check against filters - $components = $this->getRequestComponents(); - if (!$this->shouldRouteBeProfiled($components)) { - return false; - } - - // Finally, check if the extension is actually loaded. - $extensionReady = $this->perfbase->isExtensionAvailable(); - if (!$extensionReady) { - throw new RuntimeException('Profiling was requested, but the Perfbase extension is not loaded.'); - } - - return true; - } - - protected function setDefaultAttributes(): void - { - parent::setDefaultAttributes(); - - // Set route information if available - $route = $this->request->route(); - $action = 'Unknown HTTP Action'; - if ($route instanceof Route) { - $action = sprintf('%s %s', $this->request->method(), $route->uri()); - } - - // Add HTTP specific attributes - $this->setAttributes([ - 'source' => 'http', - 'http_method' => $this->request->method(), - 'http_url' => $this->request->fullUrl(), - 'action' => $action, - ]); - - // Set user ID if authenticated - if (Auth::check()) { - $this->setAttribute('user', (string)Auth::id()); - } - } - - /** - * Check if the user should be profiled. - * @param Authenticatable|null $user - * @return bool - */ - private function shouldUserBeProfiled(?Authenticatable $user): bool - { - // Check if the user is authenticated and implements the ProfiledUser interface - if ($user && method_exists($user, 'shouldBeProfiled')) { - return $user->shouldBeProfiled(); - } - - // If the user is not authenticated or doesn't implement the interface, return true - return true; - } - - /** - * Get components related to the request (path, controller, method) - * @return array - */ - private function getRequestComponents(): array - { - $pathWithSlash = '/' . ltrim($this->request->path(), '/'); - $components = [ - sprintf("%s %s", $this->request->method(), $pathWithSlash), - sprintf("%s %s", $this->request->method(), $this->request->path()), - $this->request->path(), - $pathWithSlash - ]; - - $route = $this->request->route(); - if ($route instanceof Route) { - $explodedAction = explode('@', $route->getActionName()); - $components[] = $route->getActionName(); - $components[] = $route->uri(); - $components[] = '/' . ltrim($route->uri(), '/'); - $components[] = $explodedAction[0]; - - foreach ($route->methods() as $method) { - $components[] = sprintf("%s %s", $method, $route->uri()); - $components[] = sprintf("%s %s", $method, '/' . ltrim($route->uri(), '/')); - } - } - - return $components; - } - - /** - * Check if the route should be profiled based on include and exclude filters. - * @param array $components - * @return bool - */ - private function shouldRouteBeProfiled(array $components): bool - { - return $this->matchesIncludeFilters($components) - && !$this->matchesExcludeFilters($components); - } - - /** - * Check if any include filters match the request components. - * @param array $components - * @return bool - */ - private function matchesIncludeFilters(array $components): bool - { - - /** @var array $includes */ - $includes = config('perfbase.include.http', []); - - // Check if includes are an array - if (!is_array($includes)) { - throw new RuntimeException('Configured perfbase HTTP `includes` must be an array.'); - } - - // If no includes are set, no need to check further. - if (empty($includes)) { - return false; - } - - return $this->matchesFilters($components, $includes); - } - - /** - * Check if any exclude filters match the request components. - * - * @param array $components - * @return bool - */ - private function matchesExcludeFilters(array $components): bool - { - $excludes = config('perfbase.exclude.http', []); - if (!is_array($excludes)) { - throw new RuntimeException('Configured perfbase HTTP `excludes` must be an array.'); - } - - return !empty($excludes) && $this->matchesFilters($components, $excludes); - } - - /** - * Simplified filter matching using Laravel's pattern matching - * - * @param array $components The list of components to be matched against the filters. - * @param array $filters The list of filters to apply. - * @return bool Returns true if any component matches any filter; otherwise, false. - */ - public static function matchesFilters(array $components, array $filters): bool - { - return collect($filters)->some(function ($filter) use ($components) { - // Handle match-all wildcard - if ($filter === '*' || $filter === '.*') { - return true; - } - - // Handle regex patterns (enclosed in forward slashes) - if (preg_match('/^\/.*\/$/', $filter)) { - return collect($components)->some(fn($component) => preg_match($filter, $component)); - } - - // Use Laravel's string matching for all other patterns - return collect($components)->some(fn($component) => Str::is($filter, $component)); - }); - } -} diff --git a/src/Profiling/UniversalProfiler.php b/src/Profiling/UniversalProfiler.php deleted file mode 100644 index b879965..0000000 --- a/src/Profiling/UniversalProfiler.php +++ /dev/null @@ -1,93 +0,0 @@ - */ - private array $context; - - /** @var callable|null */ - private $shouldProfileCallback; - - /** - * @param string $type The type of profiling (http, console, queue, etc.) - * @param array $context Context data for the profiling session - * @param callable|null $shouldProfileCallback Optional custom logic for shouldProfile - */ - public function __construct(string $type, array $context = [], ?callable $shouldProfileCallback = null) - { - parent::__construct($type); - $this->context = $context; - $this->shouldProfileCallback = $shouldProfileCallback; - - // Set context as attributes (convert non-string values to strings) - $this->setAttributes($this->convertContextToAttributes($context)); - } - - /** - * Determine if the current context should be profiled - * - * @return bool - */ - protected function shouldProfile(): bool - { - // Use custom callback if provided - if ($this->shouldProfileCallback) { - return call_user_func($this->shouldProfileCallback, $this->context); - } - - // Default: check if this type of profiling is enabled - return config("perfbase.profile.{$this->spanName}", true); - } - - /** - * Get the context data - * - * @return array - */ - public function getContext(): array - { - return $this->context; - } - - /** - * Add context data - * - * @param array $context - * @return void - */ - public function addContext(array $context): void - { - $this->context = array_merge($this->context, $context); - $this->setAttributes($this->convertContextToAttributes($context)); - } - - /** - * Convert context array to attributes (strings only) - * - * @param array $context - * @return array - */ - private function convertContextToAttributes(array $context): array - { - $attributes = []; - - foreach ($context as $key => $value) { - if (is_string($value)) { - $attributes[$key] = $value; - } elseif (is_scalar($value)) { - $attributes[$key] = (string) $value; - } elseif (is_array($value)) { - $attributes[$key] = json_encode($value) ?: '[]'; - } else { - $attributes[$key] = serialize($value) ?: ''; - } - } - - return $attributes; - } -} \ No newline at end of file diff --git a/src/Support/FilterMatcher.php b/src/Support/FilterMatcher.php new file mode 100644 index 0000000..3da2749 --- /dev/null +++ b/src/Support/FilterMatcher.php @@ -0,0 +1,76 @@ + $components Values to test (e.g. route path, job name, command name) + * @param array $filters Patterns to match against + * @return bool + */ + public static function matches(array $components, array $filters): bool + { + foreach ($filters as $filter) { + if ($filter === '*' || $filter === '.*') { + return true; + } + + // Regex patterns enclosed in forward slashes + if (preg_match('/^\/.*\/$/', $filter)) { + foreach ($components as $component) { + if (preg_match($filter, $component)) { + return true; + } + } + continue; + } + + // Laravel string matching for everything else + foreach ($components as $component) { + if (Str::is($filter, $component)) { + return true; + } + } + } + + return false; + } + + /** + * Check if a value passes include/exclude filters from config. + * + * @param array $components Values to test + * @param string $configKey Config key prefix (e.g. 'http', 'console', 'queue') + * @return bool + */ + public static function passesConfigFilters(array $components, string $configKey): bool + { + /** @var array $includes */ + $includes = config("perfbase.include.{$configKey}", []); + if (!is_array($includes) || empty($includes)) { + return false; + } + + if (!self::matches($components, $includes)) { + return false; + } + + /** @var array $excludes */ + $excludes = config("perfbase.exclude.{$configKey}", []); + if (is_array($excludes) && !empty($excludes) && self::matches($components, $excludes)) { + return false; + } + + return true; + } +} diff --git a/tests/AbstractProfilerTest.php b/tests/AbstractProfilerTest.php index f6990ba..6ac6eec 100644 --- a/tests/AbstractProfilerTest.php +++ b/tests/AbstractProfilerTest.php @@ -151,16 +151,18 @@ public function testPassesSampleRateCheckThrowsExceptionWithNonNumeric() public function testSetDefaultAttributes() { $this->callPrivateMethod('setDefaultAttributes'); - + $attributes = $this->getPrivateProperty('attributes'); - + $this->assertArrayHasKey('hostname', $attributes); $this->assertArrayHasKey('environment', $attributes); $this->assertArrayHasKey('app_version', $attributes); $this->assertArrayHasKey('php_version', $attributes); - $this->assertArrayHasKey('user_ip', $attributes); - $this->assertArrayHasKey('user_agent', $attributes); - + + // user_ip and user_agent are HTTP-only, not in base defaults + $this->assertArrayNotHasKey('user_ip', $attributes); + $this->assertArrayNotHasKey('user_agent', $attributes); + $this->assertEquals('testing', $attributes['environment']); $this->assertEquals('1.0.0', $attributes['app_version']); $this->assertEquals(phpversion(), $attributes['php_version']); @@ -205,11 +207,73 @@ public function testStopProfilingSubmitsTrace() public function testStopProfilingWhenSpanNotStarted() { - // Just test that method can be called - $this->profiler->stopProfiling(); + // Create a client mock where stopTraceSpan returns false + $client = Mockery::mock(PerfbaseClient::class); + $client->allows('isExtensionAvailable')->andReturns(true); + $client->allows('startTraceSpan'); + $client->allows('stopTraceSpan')->andReturns(false); + $client->allows('setAttribute'); + $client->allows('reset'); + // submitTrace should NOT be called + $client->shouldNotReceive('submitTrace'); + + $this->app->instance(PerfbaseClient::class, $client); + + $profiler = new ConcreteProfiler('not_started'); + $profiler->stopProfiling(); + $this->assertTrue(true); } + public function testStopProfilingLogsOnSubmitFailure() + { + // Override mock to return failure + $failClient = Mockery::mock(PerfbaseClient::class); + $failClient->allows('isExtensionAvailable')->andReturns(true); + $failClient->allows('startTraceSpan'); + $failClient->allows('stopTraceSpan')->andReturns(true); + $failClient->allows('setAttribute'); + $failClient->allows('submitTrace')->andReturns( + SubmitResult::retryableFailure(503, 'Service Unavailable') + ); + $failClient->allows('reset'); + + $this->app->instance(PerfbaseClient::class, $failClient); + + config(['perfbase.debug' => false, 'perfbase.log_errors' => false]); + + $profiler = new ConcreteProfiler('fail_span'); + // Should not throw even though submission failed + $profiler->stopProfiling(); + + $this->assertTrue(true); + } + + public function testStopProfilingThrowsInDebugModeOnFailure() + { + $failClient = Mockery::mock(PerfbaseClient::class); + $failClient->allows('isExtensionAvailable')->andReturns(true); + $failClient->allows('startTraceSpan'); + $failClient->allows('stopTraceSpan')->andReturns(true); + $failClient->allows('setAttribute'); + $failClient->allows('submitTrace')->andReturns( + SubmitResult::permanentFailure(401, 'Unauthorized') + ); + $failClient->allows('reset'); + + $this->app->instance(PerfbaseClient::class, $failClient); + + config(['perfbase.debug' => true]); + \Perfbase\Laravel\Support\PerfbaseConfig::clearCache(); + + $profiler = new ConcreteProfiler('debug_span'); + + $this->expectException(\Perfbase\SDK\Exception\PerfbaseException::class); + $this->expectExceptionMessage('Trace submission failed'); + + $profiler->stopProfiling(); + } + private function callPrivateMethod(string $methodName, array $args = []) { $method = $this->reflection->getMethod($methodName); diff --git a/tests/ConsoleTraceLifecycleTest.php b/tests/ConsoleTraceLifecycleTest.php new file mode 100644 index 0000000..5ce9652 --- /dev/null +++ b/tests/ConsoleTraceLifecycleTest.php @@ -0,0 +1,167 @@ +perfbaseClient = Mockery::mock(PerfbaseClient::class); + $this->perfbaseClient->allows('isExtensionAvailable')->andReturns(true); + $this->perfbaseClient->allows('startTraceSpan'); + $this->perfbaseClient->allows('stopTraceSpan')->andReturns(true); + $this->perfbaseClient->allows('setAttribute'); + $this->perfbaseClient->allows('submitTrace')->andReturns(SubmitResult::success()); + $this->perfbaseClient->allows('reset'); + + $this->app->instance(Config::class, Mockery::mock(Config::class)); + $this->app->instance(PerfbaseClient::class, $this->perfbaseClient); + + config([ + 'perfbase' => [ + 'enabled' => true, + 'sample_rate' => 1.0, + 'include' => ['console' => ['*']], + 'exclude' => ['console' => []], + ], + 'app' => ['env' => 'production', 'version' => '3.0.0'], + ]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testSetsConsoleAttributes(): void + { + $lifecycle = new ConsoleTraceLifecycle('migrate'); + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertSame('console', $attrs['source']); + $this->assertSame('migrate', $attrs['action']); + } + + public function testSetsBaseAttributes(): void + { + $lifecycle = new ConsoleTraceLifecycle('db:seed'); + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertArrayHasKey('hostname', $attrs); + $this->assertSame('production', $attrs['environment']); + $this->assertSame('3.0.0', $attrs['app_version']); + $this->assertArrayHasKey('php_version', $attrs); + } + + public function testDoesNotSetHttpAttributes(): void + { + $lifecycle = new ConsoleTraceLifecycle('migrate'); + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertArrayNotHasKey('user_ip', $attrs); + $this->assertArrayNotHasKey('user_agent', $attrs); + $this->assertArrayNotHasKey('http_method', $attrs); + $this->assertArrayNotHasKey('http_url', $attrs); + } + + public function testShouldProfileReturnsTrueWhenIncluded(): void + { + config(['perfbase.include.console' => ['*']]); + + $lifecycle = new ConsoleTraceLifecycle('migrate'); + $this->assertTrue($this->callShouldProfile($lifecycle)); + } + + public function testShouldProfileReturnsFalseWhenNotIncluded(): void + { + config(['perfbase.include.console' => ['migrate*']]); + + $lifecycle = new ConsoleTraceLifecycle('queue:work'); + $this->assertFalse($this->callShouldProfile($lifecycle)); + } + + public function testShouldProfileReturnsFalseWhenExcluded(): void + { + config([ + 'perfbase.include.console' => ['*'], + 'perfbase.exclude.console' => ['queue:work'], + ]); + + $lifecycle = new ConsoleTraceLifecycle('queue:work'); + $this->assertFalse($this->callShouldProfile($lifecycle)); + } + + public function testSpanName(): void + { + $lifecycle = new ConsoleTraceLifecycle('migrate:fresh'); + + $reflection = new ReflectionClass($lifecycle); + $prop = $reflection->getProperty('spanName'); + $prop->setAccessible(true); + + $this->assertSame('console.migrate:fresh', $prop->getValue($lifecycle)); + } + + public function testSetExitCode(): void + { + $lifecycle = new ConsoleTraceLifecycle('migrate'); + $lifecycle->setExitCode(1); + + $attrs = $this->getAttributes($lifecycle); + $this->assertSame('1', $attrs['exit_code']); + } + + public function testSetExitCodeZero(): void + { + $lifecycle = new ConsoleTraceLifecycle('migrate'); + $lifecycle->setExitCode(0); + + $attrs = $this->getAttributes($lifecycle); + $this->assertSame('0', $attrs['exit_code']); + } + + /** + * @return array + */ + private function getAttributes(ConsoleTraceLifecycle $lifecycle): array + { + $reflection = new ReflectionClass($lifecycle); + $prop = $reflection->getProperty('attributes'); + $prop->setAccessible(true); + return $prop->getValue($lifecycle); + } + + private function callShouldProfile(ConsoleTraceLifecycle $lifecycle): bool + { + $reflection = new ReflectionClass($lifecycle); + $method = $reflection->getMethod('shouldProfile'); + $method->setAccessible(true); + return $method->invoke($lifecycle); + } +} diff --git a/tests/ErrorHandlingTest.php b/tests/ErrorHandlingTest.php new file mode 100644 index 0000000..3fd83a7 --- /dev/null +++ b/tests/ErrorHandlingTest.php @@ -0,0 +1,162 @@ +handleExtensionError($e); + } + + public function triggerProfilingError(\Throwable $e, string $context = ''): void + { + $this->handleProfilingError($e, $context); + } +} + +class ErrorHandlingTest extends TestCase +{ + protected function getPackageProviders($app): array + { + return [PerfbaseServiceProvider::class]; + } + + protected function setUp(): void + { + parent::setUp(); + PerfbaseConfig::clearCache(); + } + + protected function tearDown(): void + { + PerfbaseConfig::clearCache(); + parent::tearDown(); + } + + public function testDebugModeRethrowsExtensionError(): void + { + config(['perfbase.debug' => true]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Extension broke'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Extension broke'); + + $subject->triggerExtensionError($exception); + } + + public function testDebugModeRethrowsProfilingError(): void + { + config(['perfbase.debug' => true]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Profiling broke'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Profiling broke'); + + $subject->triggerProfilingError($exception, 'submit'); + } + + public function testProductionModeSilencesExtensionError(): void + { + config([ + 'perfbase.debug' => false, + 'perfbase.log_errors' => false, + ]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Should be silenced'); + + // Should not throw + $subject->triggerExtensionError($exception); + $this->assertTrue(true); + } + + public function testProductionModeSilencesProfilingError(): void + { + config([ + 'perfbase.debug' => false, + 'perfbase.log_errors' => false, + ]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Should be silenced'); + + $subject->triggerProfilingError($exception, 'event_start'); + $this->assertTrue(true); + } + + public function testDefaultDebugIsFalse(): void + { + // Don't set debug — default should be false (no rethrow) + config(['perfbase.log_errors' => false]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Should not throw'); + + $subject->triggerExtensionError($exception); + $this->assertTrue(true); + } + + public function testLoggingModeLogsExtensionError(): void + { + config([ + 'perfbase.debug' => false, + 'perfbase.log_errors' => true, + ]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Logged extension error'); + + // Should not throw, but should attempt to log + $subject->triggerExtensionError($exception); + $this->assertTrue(true); + } + + public function testLoggingModeLogsProfilingError(): void + { + config([ + 'perfbase.debug' => false, + 'perfbase.log_errors' => true, + ]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Logged profiling error'); + + $subject->triggerProfilingError($exception, 'event_start'); + $this->assertTrue(true); + } + + public function testLoggingDisabledSkipsLogger(): void + { + config([ + 'perfbase.debug' => false, + 'perfbase.log_errors' => false, + ]); + PerfbaseConfig::clearCache(); + + $subject = new ErrorHandlingTestSubject(); + $exception = new \RuntimeException('Not logged'); + + // Should not throw and should not attempt to log + $subject->triggerProfilingError($exception, 'event_stop'); + $this->assertTrue(true); + } +} diff --git a/tests/EventFlowTest.php b/tests/EventFlowTest.php new file mode 100644 index 0000000..a49b727 --- /dev/null +++ b/tests/EventFlowTest.php @@ -0,0 +1,284 @@ + stop -> submit through the actual event flow. + */ +class EventFlowTest extends TestCase +{ + private $perfbaseClient; + + protected function getPackageProviders($app): array + { + return [PerfbaseServiceProvider::class]; + } + + /** + * Set config BEFORE the service provider boots so event listeners are registered. + */ + protected function defineEnvironment($app): void + { + // Clear static cache before boot so prior test state doesn't leak + PerfbaseConfig::clearCache(); + + $app['config']->set('perfbase.enabled', true); + $app['config']->set('perfbase.api_key', 'test-key'); + $app['config']->set('perfbase.sample_rate', 1.0); + $app['config']->set('perfbase.flags', 0); + $app['config']->set('perfbase.proxy', null); + $app['config']->set('perfbase.timeout', 5); + $app['config']->set('perfbase.include.http', ['*']); + $app['config']->set('perfbase.include.console', ['*']); + $app['config']->set('perfbase.include.queue', ['*']); + $app['config']->set('perfbase.exclude.http', []); + $app['config']->set('perfbase.exclude.console', []); + $app['config']->set('perfbase.exclude.queue', []); + $app['config']->set('app.env', 'testing'); + $app['config']->set('app.version', '1.0.0'); + } + + protected function setUp(): void + { + parent::setUp(); + + PerfbaseConfig::clearCache(); + + $mockExtension = Mockery::mock(ExtensionInterface::class); + $mockExtension->shouldReceive('isAvailable')->andReturn(true); + $mockExtension->shouldReceive('startSpan')->andReturn(); + $mockExtension->shouldReceive('stopSpan')->andReturn(); + $mockExtension->shouldReceive('getSpanData')->andReturn('{}'); + $mockExtension->shouldReceive('reset')->andReturn(); + $mockExtension->shouldReceive('setAttribute')->andReturn(); + + $this->app->instance(ExtensionInterface::class, $mockExtension); + + $this->perfbaseClient = Mockery::mock(PerfbaseClient::class); + $this->perfbaseClient->allows('isExtensionAvailable')->andReturns(true); + $this->perfbaseClient->allows('startTraceSpan'); + $this->perfbaseClient->allows('stopTraceSpan')->andReturns(true); + $this->perfbaseClient->allows('setAttribute'); + $this->perfbaseClient->allows('submitTrace')->andReturns(SubmitResult::success()); + $this->perfbaseClient->allows('reset'); + + $this->app->instance(Config::class, Mockery::mock(Config::class)); + $this->app->instance(PerfbaseClient::class, $this->perfbaseClient); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + // --------------------------------------------------------------- + // Queue event flow + // --------------------------------------------------------------- + + public function testQueueJobProfilingThroughEvents(): void + { + $job = $this->createMockJob('App\Jobs\SendEmail'); + + Event::dispatch(new JobProcessing('database', $job)); + Event::dispatch(new JobProcessed('database', $job)); + + $this->perfbaseClient->shouldHaveReceived('startTraceSpan')->once(); + $this->perfbaseClient->shouldHaveReceived('stopTraceSpan')->once(); + $this->perfbaseClient->shouldHaveReceived('submitTrace')->once(); + $this->assertTrue(true); + } + + public function testQueueJobExceptionStillSubmits(): void + { + $job = $this->createMockJob('App\Jobs\FailingJob'); + $exception = new \RuntimeException('Job failed'); + + Event::dispatch(new JobProcessing('database', $job)); + Event::dispatch(new JobExceptionOccurred('database', $job, $exception)); + + $this->perfbaseClient->shouldHaveReceived('startTraceSpan')->once(); + $this->perfbaseClient->shouldHaveReceived('stopTraceSpan')->once(); + $this->perfbaseClient->shouldHaveReceived('submitTrace')->once(); + $this->perfbaseClient->shouldHaveReceived('setAttribute') + ->with('exception', 'Job failed'); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Console event flow + // --------------------------------------------------------------- + + public function testConsoleCommandProfilingThroughEvents(): void + { + $input = new ArrayInput([]); + $output = new NullOutput(); + + Event::dispatch(new CommandStarting('migrate', $input, $output)); + Event::dispatch(new CommandFinished('migrate', $input, $output, 0)); + + $this->perfbaseClient->shouldHaveReceived('startTraceSpan')->once(); + $this->perfbaseClient->shouldHaveReceived('stopTraceSpan')->once(); + $this->perfbaseClient->shouldHaveReceived('submitTrace')->once(); + $this->assertTrue(true); + } + + public function testConsoleCommandExitCodeIsRecorded(): void + { + $input = new ArrayInput([]); + $output = new NullOutput(); + + Event::dispatch(new CommandStarting('migrate', $input, $output)); + Event::dispatch(new CommandFinished('migrate', $input, $output, 1)); + + $this->perfbaseClient->shouldHaveReceived('setAttribute') + ->with('exit_code', '1'); + $this->assertTrue(true); + } + + public function testNullCommandIsIgnored(): void + { + $event = new CommandStarting(null, new ArrayInput([]), new NullOutput()); + Event::dispatch($event); + + $this->perfbaseClient->shouldNotHaveReceived('startTraceSpan'); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Sample rate + // --------------------------------------------------------------- + + public function testSampleRateZeroSkipsStartProfiling(): void + { + config(['perfbase.sample_rate' => 0.0]); + + $job = $this->createMockJob('App\Jobs\SomeJob'); + Event::dispatch(new JobProcessing('database', $job)); + Event::dispatch(new JobProcessed('database', $job)); + + // startTraceSpan should never be called because sample rate check fails + $this->perfbaseClient->shouldNotHaveReceived('startTraceSpan'); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Multiple concurrent jobs + // --------------------------------------------------------------- + + public function testTwoConcurrentJobsTrackedIndependently(): void + { + $job1 = $this->createMockJob('App\Jobs\JobA', 'job-1'); + $job2 = $this->createMockJob('App\Jobs\JobB', 'job-2'); + + // Start both + Event::dispatch(new JobProcessing('database', $job1)); + Event::dispatch(new JobProcessing('database', $job2)); + + // Finish in reverse order + Event::dispatch(new JobProcessed('database', $job2)); + Event::dispatch(new JobProcessed('database', $job1)); + + // Both should have been profiled + $this->perfbaseClient->shouldHaveReceived('startTraceSpan')->twice(); + $this->perfbaseClient->shouldHaveReceived('stopTraceSpan')->twice(); + $this->perfbaseClient->shouldHaveReceived('submitTrace')->twice(); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + // --------------------------------------------------------------- + // Job name fallbacks + // --------------------------------------------------------------- + + public function testJobNameFallsBackToCommandName(): void + { + $job = Mockery::mock(Job::class); + $job->shouldReceive('payload')->andReturn([ + 'data' => ['commandName' => 'App\Jobs\ViaCommandName'], + ]); + $job->shouldReceive('getQueue')->andReturn('default'); + $job->shouldReceive('getJobId')->andReturn('fallback-1'); + + Event::dispatch(new JobProcessing('database', $job)); + Event::dispatch(new JobProcessed('database', $job)); + + $this->perfbaseClient->shouldHaveReceived('startTraceSpan')->once(); + $this->assertTrue(true); + } + + public function testJobNameFallsBackToPayloadJob(): void + { + $job = Mockery::mock(Job::class); + $job->shouldReceive('payload')->andReturn([ + 'job' => 'App\Jobs\ViaPayloadJob', + 'data' => [], + ]); + $job->shouldReceive('getQueue')->andReturn('default'); + $job->shouldReceive('getJobId')->andReturn('fallback-2'); + + Event::dispatch(new JobProcessing('database', $job)); + Event::dispatch(new JobProcessed('database', $job)); + + $this->perfbaseClient->shouldHaveReceived('startTraceSpan')->once(); + $this->assertTrue(true); + } + + public function testJobNameFallsBackToUnknown(): void + { + $job = Mockery::mock(Job::class); + $job->shouldReceive('payload')->andReturn(['data' => []]); + $job->shouldReceive('getQueue')->andReturn('default'); + $job->shouldReceive('getJobId')->andReturn('fallback-3'); + + Event::dispatch(new JobProcessing('database', $job)); + Event::dispatch(new JobProcessed('database', $job)); + + $this->perfbaseClient->shouldHaveReceived('startTraceSpan')->once(); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * @return Job&\Mockery\MockInterface + */ + private function createMockJob(string $displayName, string $jobId = '1'): Job + { + $job = Mockery::mock(Job::class); + $job->shouldReceive('payload')->andReturn([ + 'displayName' => $displayName, + 'data' => ['commandName' => $displayName], + ]); + $job->shouldReceive('getQueue')->andReturn('default'); + $job->shouldReceive('getJobId')->andReturn($jobId); + return $job; + } +} diff --git a/tests/FilterMatcherConfigTest.php b/tests/FilterMatcherConfigTest.php new file mode 100644 index 0000000..83329a7 --- /dev/null +++ b/tests/FilterMatcherConfigTest.php @@ -0,0 +1,114 @@ + ['*'], + 'perfbase.exclude.http' => [], + ]); + + $this->assertTrue(FilterMatcher::passesConfigFilters(['GET /api/users'], 'http')); + } + + public function testFailsWhenNoIncludes(): void + { + config([ + 'perfbase.include.queue' => [], + 'perfbase.exclude.queue' => [], + ]); + + $this->assertFalse(FilterMatcher::passesConfigFilters(['App\Jobs\SendEmail'], 'queue')); + } + + public function testFailsWhenIncludeDoesNotMatch(): void + { + config([ + 'perfbase.include.console' => ['migrate*'], + 'perfbase.exclude.console' => [], + ]); + + $this->assertFalse(FilterMatcher::passesConfigFilters(['queue:work'], 'console')); + } + + public function testPassesWhenIncludeMatchesSpecificPattern(): void + { + config([ + 'perfbase.include.queue' => ['App\Jobs\Important*'], + 'perfbase.exclude.queue' => [], + ]); + + $this->assertTrue(FilterMatcher::passesConfigFilters(['App\Jobs\ImportantEmail'], 'queue')); + $this->assertFalse(FilterMatcher::passesConfigFilters(['App\Jobs\TrivialCleanup'], 'queue')); + } + + public function testExcludeOverridesInclude(): void + { + config([ + 'perfbase.include.http' => ['*'], + 'perfbase.exclude.http' => ['GET /health*'], + ]); + + $this->assertTrue(FilterMatcher::passesConfigFilters(['POST /api/users'], 'http')); + $this->assertFalse(FilterMatcher::passesConfigFilters(['GET /health-check'], 'http')); + } + + public function testHandlesNullIncludeConfig(): void + { + config(['perfbase.include.http' => null]); + + $this->assertFalse(FilterMatcher::passesConfigFilters(['GET /'], 'http')); + } + + public function testHandlesMissingConfigKey(): void + { + // Config key that doesn't exist at all + $this->assertFalse(FilterMatcher::passesConfigFilters(['something'], 'nonexistent')); + } + + public function testRegexPatternInConfig(): void + { + config([ + 'perfbase.include.http' => ['/^POST \/api\/.*/'], + 'perfbase.exclude.http' => [], + ]); + + $this->assertTrue(FilterMatcher::passesConfigFilters(['POST /api/users'], 'http')); + $this->assertFalse(FilterMatcher::passesConfigFilters(['GET /api/users'], 'http')); + } + + public function testMultipleIncludePatterns(): void + { + config([ + 'perfbase.include.console' => ['migrate', 'db:seed', 'queue:*'], + 'perfbase.exclude.console' => [], + ]); + + $this->assertTrue(FilterMatcher::passesConfigFilters(['migrate'], 'console')); + $this->assertTrue(FilterMatcher::passesConfigFilters(['db:seed'], 'console')); + $this->assertTrue(FilterMatcher::passesConfigFilters(['queue:work'], 'console')); + $this->assertFalse(FilterMatcher::passesConfigFilters(['horizon:work'], 'console')); + } + + public function testEmptyExcludeDoesNotBlock(): void + { + config([ + 'perfbase.include.queue' => ['*'], + 'perfbase.exclude.queue' => [], + ]); + + $this->assertTrue(FilterMatcher::passesConfigFilters(['App\Jobs\AnyJob'], 'queue')); + } +} diff --git a/tests/HttpProfilerTest.php b/tests/HttpProfilerTest.php deleted file mode 100644 index 4bde102..0000000 --- a/tests/HttpProfilerTest.php +++ /dev/null @@ -1,217 +0,0 @@ -allows('isExtensionAvailable')->andReturns(true); - $perfbaseClient->allows('startTraceSpan')->andReturns(true); - $perfbaseClient->allows('stopTraceSpan')->andReturns(true); - $perfbaseClient->allows('setAttribute')->andReturns(true); - $perfbaseClient->allows('submitTrace')->andReturns(true); - $perfbaseClient->allows('getTraceData')->andReturns('test-data'); - $perfbaseClient->allows('reset')->andReturns(true); - $this->app->instance(Config::class, $config); - $this->app->instance(PerfbaseClient::class, $perfbaseClient); - - // Set up basic config values needed for the test - config([ - 'perfbase' => [ - 'enabled' => true, - 'api_key' => 'test-key', - 'sample_rate' => 1.0, - 'sending' => [ - 'mode' => 'sync', - 'timeout' => 5, - ], - 'include' => [ - 'http' => [], - ], - 'exclude' => [ - 'http' => [], - ], - ] - ]); - - $this->request = new Request(); - $this->request->server->set('SERVER_NAME', 'localhost'); - $this->request->server->set('SERVER_PORT', 80); - $this->profiler = new HttpProfiler($this->request); - $this->reflection = new ReflectionClass(HttpProfiler::class); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function testConstructor() - { - // With standardized span naming, it should be http.METHOD.path - $this->assertEquals('http.GET./', $this->getPrivateProperty('spanName')); - } - - public function testSetResponse() - { - $response = new Response('', 200); - $this->profiler->setResponse($response); - $this->assertEquals('200', $this->getPrivateProperty('attributes')['http_status_code']); - } - - public function testShouldProfileWhenDisabled() - { - config(['perfbase.enabled' => false]); - $this->assertFalse($this->callPrivateMethod('shouldProfile')); - } - -// public function testShouldProfileWhenEnabled() -// { -// config(['perfbase.enabled' => true]); -// config(['perfbase.include.http' => ['*']]); -// config(['perfbase.exclude.http' => []]); -// -// $this->assertTrue($this->callPrivateMethod('shouldProfile')); -// } - - public function testShouldUserBeProfiled() - { - $user = new class implements Authenticatable, ProfiledUser { - public function shouldBeProfiled(): bool - { - return true; - } - - public function getAuthIdentifierName() - { - // No op - } - - public function getAuthIdentifier() - { - // No op - } - - public function getAuthPassword() - { - // No op - } - - public function getRememberToken() - { - // No op - } - - public function setRememberToken($value) - { - // No op - } - - public function getRememberTokenName() - { - // No op - } - }; - - $this->assertTrue($this->callPrivateMethod('shouldUserBeProfiled', [$user])); - } - - public function testGetRequestComponents() - { - // Create request properly with path - $this->request = Request::create('/test', 'GET'); - $this->request->server->set('SERVER_NAME', 'localhost'); - $this->request->server->set('SERVER_PORT', 80); - $this->profiler = new HttpProfiler($this->request); - - $components = $this->callPrivateMethod('getRequestComponents'); - - // The component should include both formats - $this->assertContains('GET /test', $components); - $this->assertContains('/test', $components); - $this->assertContains('test', $components); // without leading slash - } - - public function testShouldRouteBeProfiled() - { - $components = ['GET /test']; - config(['perfbase.include.http' => ['GET /test']]); - config(['perfbase.exclude.http' => []]); - - $this->assertTrue($this->callPrivateMethod('shouldRouteBeProfiled', [$components])); - } - - public function testMatchesIncludeFilters() - { - $components = ['GET /test']; - config(['perfbase.include.http' => ['GET /test']]); - - $this->assertTrue($this->callPrivateMethod('matchesIncludeFilters', [$components])); - } - - public function testMatchesExcludeFilters() - { - $components = ['GET /test']; - config(['perfbase.exclude.http' => ['GET /test']]); - - $this->assertTrue($this->callPrivateMethod('matchesExcludeFilters', [$components])); - } - - public function testSetDefaultAttributes() - { - // Create request properly with path - $this->request = Request::create('http://localhost/test', 'GET'); - $this->profiler = new HttpProfiler($this->request); - - $this->callPrivateMethod('setDefaultAttributes'); - $attributes = $this->getPrivateProperty('attributes'); - - $this->assertEquals('GET', $attributes['http_method']); - $this->assertStringContainsString('localhost', $attributes['http_url']); - $this->assertStringContainsString('/test', $attributes['http_url']); - } - - private function callPrivateMethod(string $methodName, array $args = []) - { - $method = $this->reflection->getMethod($methodName); - $method->setAccessible(true); - return $method->invokeArgs($this->profiler, $args); - } - - private function getPrivateProperty(string $propertyName) - { - $property = $this->reflection->getProperty($propertyName); - $property->setAccessible(true); - return $property->getValue($this->profiler); - } -} diff --git a/tests/HttpTraceLifecycleTest.php b/tests/HttpTraceLifecycleTest.php new file mode 100644 index 0000000..fa5315f --- /dev/null +++ b/tests/HttpTraceLifecycleTest.php @@ -0,0 +1,208 @@ +perfbaseClient = Mockery::mock(PerfbaseClient::class); + $this->perfbaseClient->allows('isExtensionAvailable')->andReturns(true); + $this->perfbaseClient->allows('startTraceSpan'); + $this->perfbaseClient->allows('stopTraceSpan')->andReturns(true); + $this->perfbaseClient->allows('setAttribute'); + $this->perfbaseClient->allows('submitTrace')->andReturns(SubmitResult::success()); + $this->perfbaseClient->allows('reset'); + + $this->app->instance(Config::class, Mockery::mock(Config::class)); + $this->app->instance(PerfbaseClient::class, $this->perfbaseClient); + + config([ + 'perfbase' => [ + 'enabled' => true, + 'api_key' => 'test-key', + 'sample_rate' => 1.0, + 'include' => ['http' => ['*']], + 'exclude' => ['http' => []], + ], + 'app' => ['env' => 'testing', 'version' => '1.0.0'], + ]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testSetsHttpAttributes(): void + { + $request = Request::create('/api/users', 'POST'); + $lifecycle = new HttpTraceLifecycle($request); + + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertSame('http', $attrs['source']); + $this->assertSame('POST', $attrs['http_method']); + $this->assertStringContainsString('/api/users', $attrs['http_url']); + $this->assertStringContainsString('POST', $attrs['action']); + } + + public function testSetsResponseStatusCode(): void + { + $request = Request::create('/test', 'GET'); + $lifecycle = new HttpTraceLifecycle($request); + $lifecycle->setResponse(new Response('', 404)); + + $attrs = $this->getAttributes($lifecycle); + $this->assertSame('404', $attrs['http_status_code']); + } + + public function testSetsBaseAttributes(): void + { + $request = Request::create('/test', 'GET'); + $lifecycle = new HttpTraceLifecycle($request); + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertArrayHasKey('hostname', $attrs); + $this->assertSame('testing', $attrs['environment']); + $this->assertSame('1.0.0', $attrs['app_version']); + $this->assertArrayHasKey('php_version', $attrs); + $this->assertArrayHasKey('user_ip', $attrs); + $this->assertArrayHasKey('user_agent', $attrs); + } + + public function testShouldProfileReturnsFalseWhenDisabled(): void + { + config(['perfbase.enabled' => false]); + + $request = Request::create('/test', 'GET'); + $lifecycle = new HttpTraceLifecycle($request); + + $result = $this->callShouldProfile($lifecycle); + $this->assertFalse($result); + } + + public function testShouldProfileReturnsFalseWhenExcluded(): void + { + config([ + 'perfbase.include.http' => ['*'], + 'perfbase.exclude.http' => ['GET /excluded*'], + ]); + + $request = Request::create('/excluded-path', 'GET'); + $lifecycle = new HttpTraceLifecycle($request); + + $result = $this->callShouldProfile($lifecycle); + $this->assertFalse($result); + } + + public function testShouldProfileReturnsFalseWhenNotIncluded(): void + { + config(['perfbase.include.http' => ['POST /api/*']]); + + $request = Request::create('/web/page', 'GET'); + $lifecycle = new HttpTraceLifecycle($request); + + $result = $this->callShouldProfile($lifecycle); + $this->assertFalse($result); + } + + public function testShouldProfileReturnsFalseWhenExtensionUnavailable(): void + { + // Override the default mock with a fresh one that returns false + $client = Mockery::mock(PerfbaseClient::class); + $client->allows('isExtensionAvailable')->andReturns(false); + $client->allows('startTraceSpan'); + $client->allows('stopTraceSpan')->andReturns(false); + $client->allows('setAttribute'); + $client->allows('submitTrace')->andReturns(SubmitResult::success()); + $client->allows('reset'); + $this->app->instance(PerfbaseClient::class, $client); + + $request = Request::create('/test', 'GET'); + $lifecycle = new HttpTraceLifecycle($request); + + $result = $this->callShouldProfile($lifecycle); + $this->assertFalse($result); + } + + public function testShouldProfileRespectsProfiledUserInterface(): void + { + $user = Mockery::mock(ProfiledUser::class, \Illuminate\Contracts\Auth\Authenticatable::class); + $user->shouldReceive('shouldBeProfiled')->andReturn(false); + $user->shouldReceive('getAuthIdentifierName')->andReturn('id'); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $request = Request::create('/test', 'GET'); + $request->setUserResolver(fn() => $user); + + $lifecycle = new HttpTraceLifecycle($request); + + $result = $this->callShouldProfile($lifecycle); + $this->assertFalse($result); + } + + public function testShouldProfileAllowsUserWithoutInterface(): void + { + // User that doesn't implement ProfiledUser — should be allowed + $user = Mockery::mock(\Illuminate\Contracts\Auth\Authenticatable::class); + $user->shouldReceive('getAuthIdentifierName')->andReturn('id'); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $request = Request::create('/test', 'GET'); + $request->setUserResolver(fn() => $user); + + $lifecycle = new HttpTraceLifecycle($request); + + $result = $this->callShouldProfile($lifecycle); + $this->assertTrue($result); + } + + /** + * @return array + */ + private function getAttributes(HttpTraceLifecycle $lifecycle): array + { + $reflection = new ReflectionClass($lifecycle); + $prop = $reflection->getProperty('attributes'); + $prop->setAccessible(true); + return $prop->getValue($lifecycle); + } + + private function callShouldProfile(HttpTraceLifecycle $lifecycle): bool + { + $reflection = new ReflectionClass($lifecycle); + $method = $reflection->getMethod('shouldProfile'); + $method->setAccessible(true); + return $method->invoke($lifecycle); + } +} diff --git a/tests/MatchesFiltersTest.php b/tests/MatchesFiltersTest.php index a0e5cf2..5796822 100644 --- a/tests/MatchesFiltersTest.php +++ b/tests/MatchesFiltersTest.php @@ -1,6 +1,6 @@ ['GET /'], - 'expected' => true, // Exact match + 'expected' => true, ], [ 'filters' => ['POST /api/*'], - 'expected' => true, // Matches with "*" + 'expected' => true, ], [ 'filters' => ['App\Http\Controllers*'], - 'expected' => true, // Namespace prefix match with wildcard + 'expected' => true, ], [ 'filters' => ['UserController'], - 'expected' => true, // Exact match for controller + 'expected' => true, ], [ 'filters' => ['/^App\\\\Http\\\\Controllers\\\\.*$/'], - 'expected' => true, // Full regex match + 'expected' => true, ], [ 'filters' => ['GET /invalid', 'POST /other'], - 'expected' => false, // No matches + 'expected' => false, ], [ 'filters' => [], - 'expected' => false, // Empty filters should never match + 'expected' => false, ], [ 'filters' => ['*'], - 'expected' => true, // Match all + 'expected' => true, ], [ 'filters' => ['/^GET \/example\/([0-9]+)\/$/'], - 'expected' => false, // Assuming no component matches this regex + 'expected' => false, ], [ 'filters' => ['GET /example/*'], - 'expected' => false, // Assuming no component starts with 'GET /example/' + 'expected' => false, ], [ 'filters' => ['GET /example'], - 'expected' => true, // Exact match not present in components + 'expected' => true, ], [ 'filters' => ['UserController', 'App\Http\Controllers'], - 'expected' => true, // Should match 'UserController' and 'App\Http\Controllers\UserController' + 'expected' => true, ], ]; - // Run each test case foreach ($testCases as $case) { - $result = HttpProfiler::matchesFilters($components, $case['filters']); + $result = FilterMatcher::matches($components, $case['filters']); $this->assertSame($case['expected'], $result, 'Failed matching filters: "' . implode('" and "', $case['filters']) . '" against "' . implode('", "', $components) . '"'); } } -} \ No newline at end of file +} diff --git a/tests/QueueTraceLifecycleTest.php b/tests/QueueTraceLifecycleTest.php new file mode 100644 index 0000000..b6f1b09 --- /dev/null +++ b/tests/QueueTraceLifecycleTest.php @@ -0,0 +1,160 @@ +perfbaseClient = Mockery::mock(PerfbaseClient::class); + $this->perfbaseClient->allows('isExtensionAvailable')->andReturns(true); + $this->perfbaseClient->allows('startTraceSpan'); + $this->perfbaseClient->allows('stopTraceSpan')->andReturns(true); + $this->perfbaseClient->allows('setAttribute'); + $this->perfbaseClient->allows('submitTrace')->andReturns(SubmitResult::success()); + $this->perfbaseClient->allows('reset'); + + $this->app->instance(Config::class, Mockery::mock(Config::class)); + $this->app->instance(PerfbaseClient::class, $this->perfbaseClient); + + config([ + 'perfbase' => [ + 'enabled' => true, + 'sample_rate' => 1.0, + 'include' => ['queue' => ['*']], + 'exclude' => ['queue' => []], + ], + 'app' => ['env' => 'testing', 'version' => '2.0.0'], + ]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testSetsQueueAttributes(): void + { + $lifecycle = new QueueTraceLifecycle('App\Jobs\SendEmail', 'default', 'redis'); + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertSame('queue', $attrs['source']); + $this->assertSame('App\Jobs\SendEmail', $attrs['action']); + $this->assertSame('default', $attrs['queue']); + $this->assertSame('redis', $attrs['connection']); + } + + public function testSetsBaseAttributes(): void + { + $lifecycle = new QueueTraceLifecycle('App\Jobs\Test', 'high', 'database'); + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertArrayHasKey('hostname', $attrs); + $this->assertSame('testing', $attrs['environment']); + $this->assertSame('2.0.0', $attrs['app_version']); + $this->assertArrayHasKey('php_version', $attrs); + } + + public function testDoesNotSetHttpAttributes(): void + { + $lifecycle = new QueueTraceLifecycle('App\Jobs\Test', 'default', 'redis'); + $lifecycle->startProfiling(); + + $attrs = $this->getAttributes($lifecycle); + $this->assertArrayNotHasKey('user_ip', $attrs); + $this->assertArrayNotHasKey('user_agent', $attrs); + $this->assertArrayNotHasKey('http_method', $attrs); + $this->assertArrayNotHasKey('http_url', $attrs); + } + + public function testShouldProfileReturnsTrueWhenIncluded(): void + { + config(['perfbase.include.queue' => ['*']]); + + $lifecycle = new QueueTraceLifecycle('App\Jobs\AnyJob', 'default', 'redis'); + $this->assertTrue($this->callShouldProfile($lifecycle)); + } + + public function testShouldProfileReturnsFalseWhenNotIncluded(): void + { + config(['perfbase.include.queue' => ['App\Jobs\Important*']]); + + $lifecycle = new QueueTraceLifecycle('App\Jobs\TrivialCleanup', 'default', 'redis'); + $this->assertFalse($this->callShouldProfile($lifecycle)); + } + + public function testShouldProfileReturnsFalseWhenExcluded(): void + { + config([ + 'perfbase.include.queue' => ['*'], + 'perfbase.exclude.queue' => ['App\Jobs\Noisy*'], + ]); + + $lifecycle = new QueueTraceLifecycle('App\Jobs\NoisyHeartbeat', 'default', 'redis'); + $this->assertFalse($this->callShouldProfile($lifecycle)); + } + + public function testSpanNameUsesClassBasename(): void + { + $lifecycle = new QueueTraceLifecycle('App\Jobs\Nested\DeepJob', 'default', 'redis'); + + $reflection = new ReflectionClass($lifecycle); + $prop = $reflection->getProperty('spanName'); + $prop->setAccessible(true); + + $this->assertSame('queue.DeepJob', $prop->getValue($lifecycle)); + } + + public function testSetExceptionAttribute(): void + { + $lifecycle = new QueueTraceLifecycle('App\Jobs\Failing', 'default', 'redis'); + $lifecycle->setException('Something went wrong'); + + $attrs = $this->getAttributes($lifecycle); + $this->assertSame('Something went wrong', $attrs['exception']); + } + + /** + * @return array + */ + private function getAttributes(QueueTraceLifecycle $lifecycle): array + { + $reflection = new ReflectionClass($lifecycle); + $prop = $reflection->getProperty('attributes'); + $prop->setAccessible(true); + return $prop->getValue($lifecycle); + } + + private function callShouldProfile(QueueTraceLifecycle $lifecycle): bool + { + $reflection = new ReflectionClass($lifecycle); + $method = $reflection->getMethod('shouldProfile'); + $method->setAccessible(true); + return $method->invoke($lifecycle); + } +} diff --git a/tests/UniversalProfilerTest.php b/tests/UniversalProfilerTest.php deleted file mode 100644 index 261c4bf..0000000 --- a/tests/UniversalProfilerTest.php +++ /dev/null @@ -1,373 +0,0 @@ -shouldReceive('isAvailable')->andReturn(true); - $mockExtension->shouldReceive('startSpan')->andReturn(); - $mockExtension->shouldReceive('stopSpan')->andReturn(); - $mockExtension->shouldReceive('getSpanData')->andReturn('{}'); - $mockExtension->shouldReceive('reset')->andReturn(); - - $this->app->instance(ExtensionInterface::class, $mockExtension); - - // Set up basic config - config([ - 'perfbase' => [ - 'enabled' => true, - 'api_key' => 'test-key', - 'flags' => 0, - 'sample_rate' => 1.0, - 'proxy' => null, - 'timeout' => 5, - ] - ]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function testConstructorWithBasicType() - { - $profiler = new UniversalProfiler('test'); - - $this->assertInstanceOf(UniversalProfiler::class, $profiler); - } - - public function testConstructorWithContext() - { - $context = [ - 'user_id' => 123, - 'action' => 'test_action', - 'metadata' => ['key' => 'value'] - ]; - - $profiler = new UniversalProfiler('test', $context); - - $this->assertEquals($context, $profiler->getContext()); - } - - public function testConstructorWithCustomCallback() - { - $callback = function($context) { - return $context['should_profile'] ?? false; - }; - - $context = ['should_profile' => true]; - $profiler = new UniversalProfiler('test', $context, $callback); - - // Access the protected shouldProfile method - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - $this->assertTrue($method->invoke($profiler)); - } - - public function testConstructorWithCustomCallbackFalse() - { - $callback = function($context) { - return $context['should_profile'] ?? false; - }; - - $context = ['should_profile' => false]; - $profiler = new UniversalProfiler('test', $context, $callback); - - // Access the protected shouldProfile method - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - $this->assertFalse($method->invoke($profiler)); - } - - public function testShouldProfileWithDefaultBehavior() - { - config(['perfbase.profile.test' => true]); - - $profiler = new UniversalProfiler('test'); - - // Access the protected shouldProfile method - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - $this->assertTrue($method->invoke($profiler)); - } - - public function testShouldProfileWithDefaultBehaviorFalse() - { - config(['perfbase.profile.test' => false]); - - $profiler = new UniversalProfiler('test'); - - // Access the protected shouldProfile method - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - $this->assertFalse($method->invoke($profiler)); - } - - public function testShouldProfileWithMissingConfig() - { - // Don't set any config for this type - $profiler = new UniversalProfiler('unknown-type'); - - // Access the protected shouldProfile method - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - // Should default to true - $this->assertTrue($method->invoke($profiler)); - } - - public function testGetContext() - { - $context = [ - 'user_id' => 123, - 'action' => 'test_action' - ]; - - $profiler = new UniversalProfiler('test', $context); - - $this->assertEquals($context, $profiler->getContext()); - } - - public function testGetContextEmpty() - { - $profiler = new UniversalProfiler('test'); - - $this->assertEquals([], $profiler->getContext()); - } - - public function testAddContext() - { - $initialContext = ['user_id' => 123]; - $profiler = new UniversalProfiler('test', $initialContext); - - $additionalContext = ['action' => 'test_action', 'metadata' => ['key' => 'value']]; - $profiler->addContext($additionalContext); - - $expectedContext = array_merge($initialContext, $additionalContext); - $this->assertEquals($expectedContext, $profiler->getContext()); - } - - public function testAddContextOverwrite() - { - $initialContext = ['user_id' => 123, 'action' => 'initial']; - $profiler = new UniversalProfiler('test', $initialContext); - - $additionalContext = ['action' => 'updated', 'new_key' => 'new_value']; - $profiler->addContext($additionalContext); - - $expectedContext = ['user_id' => 123, 'action' => 'updated', 'new_key' => 'new_value']; - $this->assertEquals($expectedContext, $profiler->getContext()); - } - - public function testAddContextEmpty() - { - $initialContext = ['user_id' => 123]; - $profiler = new UniversalProfiler('test', $initialContext); - - $profiler->addContext([]); - - $this->assertEquals($initialContext, $profiler->getContext()); - } - - public function testContextSetsAttributes() - { - $context = [ - 'user_id' => 123, - 'action' => 'test_action' - ]; - - $profiler = new UniversalProfiler('test', $context); - - // Access the protected attributes property - $reflection = new \ReflectionClass($profiler); - $property = $reflection->getProperty('attributes'); - $property->setAccessible(true); - - $attributes = $property->getValue($profiler); - - // Context should be set as attributes - $this->assertEquals(123, $attributes['user_id']); - $this->assertEquals('test_action', $attributes['action']); - } - - public function testAddContextUpdatesAttributes() - { - $profiler = new UniversalProfiler('test'); - - $context = ['user_id' => 123]; - $profiler->addContext($context); - - // Access the protected attributes property - $reflection = new \ReflectionClass($profiler); - $property = $reflection->getProperty('attributes'); - $property->setAccessible(true); - - $attributes = $property->getValue($profiler); - - // New context should be added to attributes - $this->assertEquals(123, $attributes['user_id']); - } - - public function testCustomCallbackWithComplexLogic() - { - $callback = function($context) { - // Complex logic: profile only if user is admin and action is important - return ($context['user_role'] ?? '') === 'admin' && - in_array($context['action'] ?? '', ['create', 'update', 'delete']); - }; - - // Test admin with important action - $context = ['user_role' => 'admin', 'action' => 'create']; - $profiler = new UniversalProfiler('test', $context, $callback); - - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - $this->assertTrue($method->invoke($profiler)); - - // Test admin with unimportant action - $context = ['user_role' => 'admin', 'action' => 'view']; - $profiler = new UniversalProfiler('test', $context, $callback); - - $this->assertFalse($method->invoke($profiler)); - - // Test non-admin with important action - $context = ['user_role' => 'user', 'action' => 'create']; - $profiler = new UniversalProfiler('test', $context, $callback); - - $this->assertFalse($method->invoke($profiler)); - } - - public function testCallbackReceivesCorrectContext() - { - $receivedContext = null; - $callback = function($context) use (&$receivedContext) { - $receivedContext = $context; - return true; - }; - - $originalContext = ['user_id' => 123, 'action' => 'test']; - $profiler = new UniversalProfiler('test', $originalContext, $callback); - - // Trigger shouldProfile - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - $method->invoke($profiler); - - $this->assertEquals($originalContext, $receivedContext); - } - - public function testCallbackReceivesUpdatedContext() - { - $receivedContext = null; - $callback = function($context) use (&$receivedContext) { - $receivedContext = $context; - return true; - }; - - $profiler = new UniversalProfiler('test', ['initial' => 'value'], $callback); - $profiler->addContext(['added' => 'value']); - - // Trigger shouldProfile - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - $method->invoke($profiler); - - $expectedContext = ['initial' => 'value', 'added' => 'value']; - $this->assertEquals($expectedContext, $receivedContext); - } - - public function testInheritsFromAbstractProfiler() - { - $profiler = new UniversalProfiler('test'); - - $this->assertInstanceOf(\Perfbase\Laravel\Profiling\AbstractProfiler::class, $profiler); - } - - public function testCanStartAndStopProfiling() - { - $profiler = new UniversalProfiler('test'); - - // These methods should be available from AbstractProfiler - $this->assertTrue(method_exists($profiler, 'startProfiling')); - $this->assertTrue(method_exists($profiler, 'stopProfiling')); - } - - public function testWorksWithDifferentTypes() - { - $httpProfiler = new UniversalProfiler('http.GET./api/users'); - $queueProfiler = new UniversalProfiler('queue.ProcessPodcast'); - $consoleProfiler = new UniversalProfiler('console.migrate'); - - $this->assertInstanceOf(UniversalProfiler::class, $httpProfiler); - $this->assertInstanceOf(UniversalProfiler::class, $queueProfiler); - $this->assertInstanceOf(UniversalProfiler::class, $consoleProfiler); - } - - public function testHandlesNullCallback() - { - $profiler = new UniversalProfiler('test', [], null); - - config(['perfbase.profile.test' => true]); - - // Access the protected shouldProfile method - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - // Should fall back to default behavior - $this->assertTrue($method->invoke($profiler)); - } - - public function testHandlesCallbackException() - { - $callback = function($context) { - throw new \Exception('Callback error'); - }; - - $profiler = new UniversalProfiler('test', [], $callback); - - // Access the protected shouldProfile method - $reflection = new \ReflectionClass($profiler); - $method = $reflection->getMethod('shouldProfile'); - $method->setAccessible(true); - - // Should handle exception gracefully - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Callback error'); - $method->invoke($profiler); - } -} \ No newline at end of file