├── tests ├── Unit │ ├── HelperTest.php │ ├── Commands │ │ ├── RecordCommandTest.php │ │ ├── DiagnoseCommandTest.php │ │ ├── InstallCommandTest.php │ │ └── TestInternalCommandTest.php │ ├── PackageTest.php │ ├── ServiceProviderTest.php │ ├── ConfigTest.php │ ├── Listeners │ │ ├── QueueEventListenerTest.php │ │ ├── CacheEventListenerTest.php │ │ ├── DatabaseEventListenerTest.php │ │ └── HttpEventListenerTest.php │ ├── ResponseTypesTest.php │ └── Services │ │ ├── PIIScrubberTest.php │ │ └── TraceRecorderTest.php ├── Pest.php ├── Feature │ ├── ChronotraceIntegrationTest.php │ ├── Commands │ │ ├── PurgeCommandTest.php │ │ ├── MiddlewareTestCommandTest.php │ │ ├── ReplayCommandTest.php │ │ └── ListCommandTest.php │ ├── ChronotraceStorageTest.php │ └── ReplayGenerateTestTest.php ├── TestCase.php └── Integration │ ├── EventListenersIntegrationTest.php │ ├── ListCommandFixTest.php │ ├── ArtisanCommandsTest.php │ ├── MiddlewareIntegrationTest.php │ ├── TraceRecorderIntegrationTest.php │ ├── MiddlewareResponseTypesTest.php │ └── MiddlewareResponseFixTest.php ├── new_logo.png ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── tests.yml │ ├── docs.yml │ ├── refactor.yml │ ├── config.yml │ ├── enhancement.yml │ ├── feature.yml │ └── bug.yml ├── workflows │ ├── sync-labels.yml │ ├── code-quality.yml │ ├── tests.yml │ └── release.yml ├── pull_request_template.md └── labels.yml ├── testbench.yaml ├── .gitignore ├── pint.json ├── src ├── Contracts │ ├── TestGeneratorInterface.php │ ├── OutputFormatterInterface.php │ └── EventDisplayerInterface.php ├── Display │ ├── Formatters │ │ ├── RawOutputFormatter.php │ │ └── JsonOutputFormatter.php │ ├── Events │ │ ├── CacheEventDisplayer.php │ │ ├── JobEventDisplayer.php │ │ ├── HttpEventDisplayer.php │ │ └── DatabaseEventDisplayer.php │ └── AbstractEventDisplayer.php ├── Commands │ ├── PurgeCommand.php │ ├── ListCommand.php │ ├── MiddlewareTestCommand.php │ ├── RecordCommand.php │ └── InstallCommand.php ├── Jobs │ └── StoreTraceJob.php ├── Models │ ├── TraceResponse.php │ ├── TraceContext.php │ ├── TraceRequest.php │ └── TraceData.php ├── Listeners │ ├── QueueEventListener.php │ ├── DatabaseEventListener.php │ ├── CacheEventListener.php │ └── HttpEventListener.php ├── Services │ └── PIIScrubber.php └── Middleware │ └── ChronoTraceMiddleware.php ├── phpstan-baseline.neon ├── rector.php ├── LICENSE.md ├── phpunit.xml ├── RELEASES.md ├── phpstan.neon ├── CONTRIBUTING.md ├── composer.json ├── INSTALLATION.md ├── SECURITY.md ├── config └── chronotrace.php └── CODE_OF_CONDUCT.md /tests/Unit/HelperTest.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /new_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Grazulex/laravel-chronotrace/HEAD/new_logo.png -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature', 'Unit'); 8 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # All source code and config changes need @Grazulex approval 2 | /src/ @Grazulex 3 | /tests/ @Grazulex 4 | **/*.{yml,yaml} @Grazulex 5 | **/*.md @Grazulex 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Grazulex 2 | buy_me_a_coffee: Grazulex 3 | custom: ["https://paypal.me/strauven"] 4 | 5 | #buy_me_a_coffee: Replace with a single Buy Me a Coffee username 6 | -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | - Grazulex\LaravelChronotrace\LaravelChronotraceServiceProvider 3 | 4 | workbench: 5 | start: "/" 6 | user: 1 7 | 8 | install: true 9 | welcome: false 10 | 11 | build: 12 | - env-file: .env.example 13 | - migrate: true 14 | - seed: true 15 | 16 | assets: 17 | - from: "tests/Fixtures" 18 | to: "tests/Fixtures" 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /node_modules/ 3 | .env 4 | .env.* 5 | /storage/*.key 6 | /storage/app/public 7 | /storage/debugbar 8 | /storage/framework/cache/data 9 | /storage/framework/sessions 10 | /storage/framework/testing 11 | /storage/framework/views 12 | /storage/logs 13 | /public/storage 14 | .phpunit.result.cache 15 | Homestead.json 16 | Homestead.yaml 17 | npm-debug.log 18 | yarn-error.log 19 | yarn-debug.log 20 | /.idea 21 | /.vscode 22 | .DS_Store 23 | Thumbs.db 24 | composer.lock 25 | package-lock.json 26 | .env.backup 27 | .env.example.local 28 | /.phpunit.cache 29 | cache.sqlite 30 | /build -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "simplified_null_return": true, 5 | "braces": { 6 | "position_after_control_structures": "same" 7 | }, 8 | "concat_space": { 9 | "spacing": "one" 10 | }, 11 | "not_operator_with_successor_space": true, 12 | "ordered_imports": { 13 | "sort_algorithm": "alpha" 14 | }, 15 | "php_unit_strict": true, 16 | "phpdoc_separation": true, 17 | "single_quote": true, 18 | "ternary_operator_spaces": true, 19 | "trailing_comma_in_multiline": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tests.yml: -------------------------------------------------------------------------------- 1 | name: "🧪 Add or Improve Test Coverage" 2 | description: Propose new or improved test cases 3 | labels: ["scope:tests"] 4 | body: 5 | - type: textarea 6 | id: missing 7 | attributes: 8 | label: Missing Tests 9 | description: Describe the feature or behavior that is not covered by tests. 10 | placeholder: | 11 | The `dto:definition-list` command is not tested with the `--compact` flag... 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: proposal 17 | attributes: 18 | label: Suggested Tests 19 | description: How would you cover this with a test? 20 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync Labels 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - '.github/labels.yml' 8 | - '.github/workflows/sync-labels.yml' 9 | workflow_dispatch: 10 | 11 | permissions: 12 | issues: write 13 | contents: read 14 | 15 | jobs: 16 | sync-labels: 17 | runs-on: ubuntu-latest 18 | name: Sync repository labels 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Sync labels 25 | uses: EndBug/label-sync@v2 26 | with: 27 | config-file: .github/labels.yml 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | delete-other-labels: true 30 | -------------------------------------------------------------------------------- /src/Contracts/TestGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | true]); 7 | }); 8 | 9 | it('handles invalid JSON data', function (): void { 10 | $this->artisan(RecordCommand::class, [ 11 | 'url' => 'https://httpbin.org/get', 12 | '--data' => 'invalid-json', 13 | ]) 14 | ->expectsOutputToContain('Invalid JSON in --data option') 15 | ->assertExitCode(1); 16 | }); 17 | 18 | it('can instantiate command', function (): void { 19 | $command = new RecordCommand; 20 | expect($command)->toBeInstanceOf(RecordCommand::class); 21 | }); 22 | 23 | it('has correct signature', function (): void { 24 | $command = new RecordCommand; 25 | expect($command->getName())->toBe('chronotrace:record'); 26 | }); 27 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | code-quality: 14 | runs-on: ubuntu-latest 15 | name: Code Quality (PHP 8.4) 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: 8.4 25 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv 26 | coverage: none 27 | 28 | - name: Install dependencies 29 | run: composer install --prefer-dist --no-interaction --no-progress 30 | 31 | - name: Run PHPStan 32 | run: vendor/bin/phpstan analyse --memory-limit=2G -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: "🚀 Enhancement" 2 | description: Suggest improvements to an existing feature 3 | labels: ["type:enhancement", "status:discussion"] 4 | body: 5 | - type: textarea 6 | id: current 7 | attributes: 8 | label: Current Behavior 9 | description: What does the current feature do? 10 | placeholder: | 11 | The generator outputs all DTOs in one flat directory... 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: suggestion 17 | attributes: 18 | label: Suggested Improvement 19 | description: How could this be improved? 20 | placeholder: | 21 | Allow grouping DTOs in subdirectories based on namespace... 22 | validations: 23 | required: true 24 | 25 | - type: dropdown 26 | id: scope 27 | attributes: 28 | label: Area 29 | options: 30 | - generator 31 | - validation 32 | - docs 33 | - tests 34 | - other 35 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | # Ignore array type specifications for now 4 | - '#has no value type specified in iterable type array#' 5 | - '#Method .* return type has no value type specified in iterable type array#' 6 | - '#Parameter .* expects array, mixed given#' 7 | - '#Cannot access offset .* on mixed#' 8 | - '#Cannot access an offset on mixed#' 9 | - '#Property .* type has no value type specified in iterable type array#' 10 | - '#Parameter .* expects string, mixed given#' 11 | - '#Parameter .* expects int, mixed given#' 12 | - '#Parameter .* expects float, mixed given#' 13 | - '#Parameter .* expects bool, mixed given#' 14 | 15 | # Ignore method return type issues for complex arrays 16 | - '#Method .* should return .* but returns mixed#' 17 | 18 | # Ignore specific Laravel config access patterns 19 | - '#Called ''env'' outside of the config directory#' 20 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 11 | __DIR__ . '/src', 12 | __DIR__ . '/tests', 13 | ]); 14 | 15 | // Define what rule sets will be applied 16 | $rectorConfig->sets([ 17 | LevelSetList::UP_TO_PHP_83, 18 | SetList::CODE_QUALITY, 19 | SetList::DEAD_CODE, 20 | SetList::EARLY_RETURN, 21 | SetList::TYPE_DECLARATION, 22 | SetList::PRIVATIZATION, 23 | ]); 24 | 25 | // Skip some rules or files 26 | $rectorConfig->skip([ 27 | // Skip specific rules 28 | // \Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPromotedPropertyRector::class, 29 | ]); 30 | 31 | // Auto-import fully qualified class names 32 | $rectorConfig->importNames(); 33 | $rectorConfig->importShortClasses(); 34 | }; 35 | -------------------------------------------------------------------------------- /src/Contracts/EventDisplayerInterface.php: -------------------------------------------------------------------------------- 1 | $options Options d'affichage 19 | */ 20 | public function display(Command $command, TraceData $trace, array $options = []): void; 21 | 22 | /** 23 | * Retourne le nom du type d'événement 24 | */ 25 | public function getEventType(): string; 26 | 27 | /** 28 | * Vérifie si ce displayer peut gérer ce type d'événement 29 | */ 30 | public function canHandle(string $eventType): bool; 31 | 32 | /** 33 | * Génère un résumé statistique pour ce type d'événement 34 | * 35 | * @param array $events 36 | */ 37 | public function getSummary(array $events): array; 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jean-Marc Strauven 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | tests/Unit 18 | 19 | 20 | tests/Feature 21 | 22 | 23 | 24 | 25 | src 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature Request 2 | description: Suggest a new idea or feature for Laravel Chronotrace 3 | title: "[Feature] " 4 | labels: [type:feature] 5 | body: 6 | 7 | - type: dropdown 8 | id: area 9 | attributes: 10 | label: Area / Component 11 | description: Which part of the codebase is affected? 12 | options: 13 | - commands 14 | - tests 15 | - docs 16 | - config 17 | - cli 18 | - internal 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: need 24 | attributes: 25 | label: What problem does this feature solve? 26 | placeholder: | 27 | Describe the pain point or need you're trying to address. 28 | Example: "Currently there's no support for test promotion..." 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: solution 34 | attributes: 35 | label: Describe your proposed solution 36 | placeholder: | 37 | How would you implement this feature? Include examples or expected command syntax if possible. 38 | validations: 39 | required: true 40 | -------------------------------------------------------------------------------- /src/Commands/PurgeCommand.php: -------------------------------------------------------------------------------- 1 | option('days'); 18 | $confirm = $this->option('confirm'); 19 | 20 | if (! $confirm && ! $this->confirm("Delete traces older than {$days} days?")) { 21 | $this->info('Purge cancelled.'); 22 | 23 | return Command::SUCCESS; 24 | } 25 | 26 | $this->info("Purging traces older than {$days} days..."); 27 | 28 | try { 29 | $deleted = $storage->purgeOldTraces($days); 30 | $this->info("Successfully purged {$deleted} traces."); 31 | } catch (Exception $e) { 32 | $this->error("Failed to purge traces: {$e->getMessage()}"); 33 | 34 | return Command::FAILURE; 35 | } 36 | 37 | return Command::SUCCESS; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- 1 | # Release Notes for Laravel Chronotrace 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | - Initial release 7 | - Laravel Chronotrace functionality 8 | - Comprehensive test suite 9 | - Documentation 10 | 11 | ### Changed 12 | - Nothing yet 13 | 14 | ### Deprecated 15 | - Nothing yet 16 | 17 | ### Removed 18 | - Nothing yet 19 | 20 | ### Fixed 21 | - Nothing yet 22 | 23 | ### Security 24 | - Nothing yet 25 | 26 | --- 27 | 28 | ## How to read this changelog 29 | 30 | This changelog follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) principles. 31 | 32 | ### Types of changes 33 | - **Added** for new features 34 | - **Changed** for changes in existing functionality 35 | - **Deprecated** for soon-to-be removed features 36 | - **Removed** for now removed features 37 | - **Fixed** for any bug fixes 38 | - **Security** in case of vulnerabilities 39 | 40 | ### Version format 41 | This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 42 | 43 | Given a version number MAJOR.MINOR.PATCH: 44 | - **MAJOR** version when you make incompatible API changes 45 | - **MINOR** version when you add functionality in a backwards compatible manner 46 | - **PATCH** version when you make backwards compatible bug fixes 47 | -------------------------------------------------------------------------------- /tests/Unit/Commands/DiagnoseCommandTest.php: -------------------------------------------------------------------------------- 1 | artisan(DiagnoseCommand::class) 7 | ->expectsOutputToContain('🔍 ChronoTrace Configuration Diagnosis') 8 | ->expectsOutputToContain('General Configuration') 9 | ->expectsOutputToContain('Storage Configuration') 10 | ->assertExitCode(0); 11 | }); 12 | 13 | it('checks queue configuration', function (): void { 14 | $this->artisan(DiagnoseCommand::class) 15 | ->expectsOutputToContain('⚡ Queue Configuration') 16 | ->expectsOutputToContain('queue_connection') 17 | ->assertExitCode(0); 18 | }); 19 | 20 | it('shows storage configuration', function (): void { 21 | $this->artisan(DiagnoseCommand::class) 22 | ->expectsOutputToContain('💾 Storage Configuration') 23 | ->assertExitCode(0); 24 | }); 25 | 26 | it('tests configuration settings', function (): void { 27 | $this->artisan(DiagnoseCommand::class) 28 | ->expectsOutputToContain('enabled') 29 | ->expectsOutputToContain('mode') 30 | ->expectsOutputToContain('storage') 31 | ->assertExitCode(0); 32 | }); 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: "🐞 Bug Report" 2 | description: Report a reproducible bug in Laravel Chronotrace 3 | labels: ["type:bug", "status:discussion"] 4 | body: 5 | - type: input 6 | id: version 7 | attributes: 8 | label: Package Version 9 | description: What version of Laravel Chronotrace are you using? 10 | placeholder: "e.g. 0.1.0" 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: description 16 | attributes: 17 | label: Bug Description 18 | description: What is happening and what did you expect instead? 19 | placeholder: | 20 | When running `tdd:make`, I get a `TypeError` when... 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: steps 26 | attributes: 27 | label: Steps to Reproduce 28 | description: How can we reproduce this bug? 29 | placeholder: | 30 | 1. Run this command 31 | 2. Use this input YAML 32 | 3. Observe this output... 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | id: logs 38 | attributes: 39 | label: Logs or Screenshots 40 | description: If applicable, paste logs or screenshots that help explain the issue. 41 | validations: 42 | required: false 43 | -------------------------------------------------------------------------------- /tests/Feature/ChronotraceIntegrationTest.php: -------------------------------------------------------------------------------- 1 | toHaveKey('vendor:publish'); 12 | }); 13 | 14 | it('loads chronotrace config in laravel app', function (): void { 15 | // Vérifier que la config est chargée 16 | expect(config('chronotrace'))->not()->toBeNull(); 17 | expect(config('chronotrace'))->toBeArray(); 18 | }); 19 | 20 | it('has chronotrace enabled in test environment', function (): void { 21 | // Vérifier que chronotrace est activé pour les tests 22 | expect(config('chronotrace.enabled'))->toBeTrue(); 23 | }); 24 | 25 | it('can access chronotrace config values', function (): void { 26 | expect(config('chronotrace.mode'))->toBe('record_on_error'); 27 | expect(config('chronotrace.storage'))->toBe('local'); 28 | expect(config('chronotrace.retention_days'))->toBe(15); 29 | }); 30 | 31 | it('has correct application environment setup', function (): void { 32 | /** @var Tests\TestCase $this */ 33 | expect($this->getApp()->environment())->toBe('testing'); 34 | expect(config('database.default'))->toBe('testing'); 35 | }); 36 | -------------------------------------------------------------------------------- /src/Jobs/StoreTraceJob.php: -------------------------------------------------------------------------------- 1 | store($this->traceData); 34 | } catch (Throwable $exception) { 35 | // Log l'erreur mais ne pas faire échouer le job 36 | logger()->error('Failed to store ChronoTrace', [ 37 | 'trace_id' => $this->traceData->traceId, 38 | 'error' => $exception->getMessage(), 39 | ]); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Unit/PackageTest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 10 | }); 11 | 12 | it('config file path is accessible', function (): void { 13 | $configPath = __DIR__ . '/../../config/chronotrace.php'; 14 | expect(file_exists($configPath))->toBeTrue(); 15 | expect(is_readable($configPath))->toBeTrue(); 16 | }); 17 | 18 | it('application can boot without errors', function (): void { 19 | /** @var Tests\TestCase $this */ 20 | // Test que l'application peut démarrer sans erreur 21 | $app = $this->getApp(); 22 | expect($app)->toBeInstanceOf(Application::class); 23 | expect($app->isBooted())->toBeTrue(); 24 | }); 25 | 26 | it('has no conflicting service providers', function (): void { 27 | /** @var Tests\TestCase $this */ 28 | $providers = $this->getApp()->getLoadedProviders(); 29 | 30 | // Vérifier qu'il n'y a qu'une instance de notre service provider 31 | $chronotraceProviders = array_filter($providers, fn ($provider, $key): bool => str_contains($key, 'Chronotrace'), ARRAY_FILTER_USE_BOTH); 32 | 33 | expect(count($chronotraceProviders))->toBe(1); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/Feature/Commands/PurgeCommandTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('purgeOldTraces') 9 | ->with(30) 10 | ->once() 11 | ->andReturn(5); 12 | 13 | $this->instance(TraceStorage::class, $storage); 14 | 15 | $this->artisan(PurgeCommand::class, ['--confirm' => true]) 16 | ->expectsOutput('Purging traces older than 30 days...') 17 | ->expectsOutput('Successfully purged 5 traces.') 18 | ->assertExitCode(0); 19 | }); 20 | 21 | it('cancels purge without confirmation', function (): void { 22 | $storage = Mockery::mock(TraceStorage::class); 23 | $storage->shouldNotReceive('purgeOldTraces'); 24 | 25 | $this->instance(TraceStorage::class, $storage); 26 | 27 | $this->artisan(PurgeCommand::class) 28 | ->expectsConfirmation('Delete traces older than 30 days?', 'no') 29 | ->expectsOutput('Purge cancelled.') 30 | ->assertExitCode(0); 31 | }); 32 | 33 | it('handles purge errors gracefully', function (): void { 34 | $storage = Mockery::mock(TraceStorage::class); 35 | $storage->shouldReceive('purgeOldTraces') 36 | ->once() 37 | ->andThrow(new Exception('Purge error')); 38 | 39 | $this->instance(TraceStorage::class, $storage); 40 | 41 | $this->artisan(PurgeCommand::class, ['--confirm' => true]) 42 | ->expectsOutput('Failed to purge traces: Purge error') 43 | ->assertExitCode(1); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/Unit/Commands/InstallCommandTest.php: -------------------------------------------------------------------------------- 1 | artisan(InstallCommand::class) 12 | ->expectsOutputToContain('Installing ChronoTrace...') 13 | ->expectsOutputToContain('✅ ChronoTrace installation completed!') 14 | ->expectsOutputToContain('🚀 You can now start using ChronoTrace:') 15 | ->assertExitCode(0); 16 | }); 17 | 18 | it('can force overwrite configuration', function (): void { 19 | $this->artisan(InstallCommand::class, ['--force' => true]) 20 | ->expectsOutputToContain('Installing ChronoTrace...') 21 | ->expectsOutputToContain('installation completed') 22 | ->assertExitCode(0); 23 | }); 24 | 25 | it('shows usage instructions after installation', function (): void { 26 | $this->artisan(InstallCommand::class) 27 | ->expectsOutputToContain('php artisan chronotrace:list') 28 | ->expectsOutputToContain('php artisan chronotrace:record https://example.com') 29 | ->assertExitCode(0); 30 | }); 31 | 32 | it('detects Laravel legacy versions', function (): void { 33 | $this->artisan(InstallCommand::class) 34 | ->expectsOutputToContain('📱 Detected Laravel') 35 | ->expectsOutputToContain('installation completed') 36 | ->assertExitCode(0); 37 | }); 38 | 39 | it('handles bootstrap app configuration', function (): void { 40 | $this->artisan(InstallCommand::class) 41 | ->expectsOutputToContain('Installing ChronoTrace...') 42 | ->assertExitCode(0); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | artisan('migrate', ['--database' => 'testing']); 22 | } 23 | 24 | final public function debugToFile(string $content, string $context = ''): void 25 | { 26 | $file = base_path('chronotrace_test.log'); 27 | $tag = $context !== '' && $context !== '0' ? "=== $context ===\n" : ''; 28 | File::append($file, $tag . $content . "\n"); 29 | } 30 | 31 | /** 32 | * Get the application instance. 33 | */ 34 | public function getApp(): Application 35 | { 36 | /** @var Application $app */ 37 | $app = $this->app; 38 | 39 | return $app; 40 | } 41 | 42 | protected function getEnvironmentSetUp($app): void 43 | { 44 | // Setup Chronotrace specific testing environment 45 | $app['config']->set('chronotrace.enabled', true); 46 | $app['config']->set('database.default', 'testing'); 47 | $app['config']->set('database.connections.testing', [ 48 | 'driver' => 'sqlite', 49 | 'database' => ':memory:', 50 | 'prefix' => '', 51 | ]); 52 | } 53 | 54 | protected function getPackageProviders($app) 55 | { 56 | return [ 57 | LaravelChronotraceServiceProvider::class, 58 | ]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main, develop ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | php: [8.4] 19 | laravel: [12.*] 20 | dependency-version: [prefer-lowest, prefer-stable] 21 | include: 22 | - laravel: 12.* 23 | testbench: 10.* 24 | exclude: 25 | - php: 8.2 26 | laravel: 12.* 27 | dependency-version: prefer-lowest 28 | 29 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 30 | 31 | steps: 32 | - name: Checkout code 33 | uses: actions/checkout@v4 34 | 35 | - name: Setup PHP 36 | uses: shivammathur/setup-php@v2 37 | with: 38 | php-version: ${{ matrix.php }} 39 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 40 | coverage: none 41 | 42 | - name: Setup problem matchers 43 | run: | 44 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 45 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 46 | 47 | - name: Install dependencies 48 | run: | 49 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 50 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 51 | 52 | - name: List Installed Dependencies 53 | run: composer show -D 54 | 55 | - name: Execute tests 56 | run: vendor/bin/pest -------------------------------------------------------------------------------- /tests/Feature/ChronotraceStorageTest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 16 | expect(File::isDirectory($storagePath))->toBeTrue(); 17 | }); 18 | 19 | it('can write to chronotrace log file', function (): void { 20 | $testLogFile = storage_path('chronotrace/test.log'); 21 | $testContent = 'Test chronotrace entry: ' . now()->toISOString(); 22 | 23 | // S'assurer que le dossier existe 24 | $storagePath = storage_path('chronotrace'); 25 | if (! File::exists($storagePath)) { 26 | File::makeDirectory($storagePath, 0755, true); 27 | } 28 | 29 | File::put($testLogFile, $testContent); 30 | 31 | expect(File::exists($testLogFile))->toBeTrue(); 32 | expect(File::get($testLogFile))->toBe($testContent); 33 | 34 | // Nettoyer après le test 35 | File::delete($testLogFile); 36 | }); 37 | 38 | it('respects scrub configuration', function (): void { 39 | $scrubFields = config('chronotrace.scrub'); 40 | 41 | expect($scrubFields)->toBeArray(); 42 | expect($scrubFields)->toContain('password'); 43 | expect($scrubFields)->toContain('token'); 44 | }); 45 | 46 | it('has valid retention policy', function (): void { 47 | $retentionDays = config('chronotrace.retention_days'); 48 | 49 | expect($retentionDays)->toBeInt(); 50 | expect($retentionDays)->toBeGreaterThan(0); 51 | expect($retentionDays)->toBeLessThanOrEqual(365); // Maximum 1 year seems reasonable 52 | }); 53 | -------------------------------------------------------------------------------- /tests/Unit/ServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | getApp()); 10 | 11 | expect($provider)->toBeInstanceOf(LaravelChronotraceServiceProvider::class); 12 | }); 13 | 14 | it('registers the service provider correctly', function (): void { 15 | /** @var Tests\TestCase $this */ 16 | $providers = $this->getApp()->getLoadedProviders(); 17 | 18 | expect($providers)->toHaveKey(LaravelChronotraceServiceProvider::class); 19 | }); 20 | it('merges config correctly', function (): void { 21 | // Le config devrait être disponible 22 | expect(config('chronotrace'))->toBeArray(); 23 | expect(config('chronotrace.enabled'))->toBeTrue(); 24 | expect(config('chronotrace.mode'))->toBe('record_on_error'); 25 | expect(config('chronotrace.sample_rate'))->toBe(0.001); 26 | expect(config('chronotrace.storage'))->toBe('local'); 27 | expect(config('chronotrace.retention_days'))->toBe(15); 28 | }); 29 | 30 | it('has correct config structure', function (): void { 31 | $config = config('chronotrace'); 32 | 33 | expect($config)->toHaveKeys([ 34 | 'enabled', 35 | 'mode', 36 | 'sample_rate', 37 | 'storage', 38 | 'path', 39 | 'retention_days', 40 | 'scrub', 41 | ]); 42 | }); 43 | 44 | it('has valid config values', function (): void { 45 | expect(config('chronotrace.mode'))->toBeIn(['always', 'sample', 'record_on_error']); 46 | expect(config('chronotrace.sample_rate'))->toBeFloat(); 47 | expect(config('chronotrace.storage'))->toBeString(); 48 | expect(config('chronotrace.retention_days'))->toBeInt(); 49 | expect(config('chronotrace.scrub'))->toBeArray(); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/Unit/ConfigTest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 9 | 10 | $config = include $configPath; 11 | 12 | expect($config)->toBeArray() 13 | ->and($config)->toHaveKeys([ 14 | 'enabled', 15 | 'mode', 16 | 'sample_rate', 17 | 'storage', 18 | 'path', 19 | 'retention_days', 20 | 'scrub', 21 | ]); 22 | }); 23 | 24 | it('has valid mode values', function (): void { 25 | $configPath = __DIR__ . '/../../config/chronotrace.php'; 26 | /** @var array $config */ 27 | $config = include $configPath; 28 | 29 | expect($config['mode'])->toBeIn(['always', 'sample', 'record_on_error']); 30 | }); 31 | 32 | it('has valid sample rate', function (): void { 33 | $configPath = __DIR__ . '/../../config/chronotrace.php'; 34 | /** @var array $config */ 35 | $config = include $configPath; 36 | 37 | expect($config['sample_rate']) 38 | ->toBeFloat() 39 | ->toBeBetween(0, 1); 40 | }); 41 | 42 | it('has valid retention days', function (): void { 43 | $configPath = __DIR__ . '/../../config/chronotrace.php'; 44 | /** @var array $config */ 45 | $config = include $configPath; 46 | 47 | expect($config['retention_days']) 48 | ->toBeInt() 49 | ->toBeGreaterThan(0); 50 | }); 51 | 52 | it('has valid scrub array', function (): void { 53 | $configPath = __DIR__ . '/../../config/chronotrace.php'; 54 | /** @var array $config */ 55 | $config = include $configPath; 56 | 57 | expect($config['scrub']) 58 | ->toBeArray() 59 | ->and($config['scrub'])->toContain('password', 'token'); 60 | }); 61 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Related Issue 6 | 7 | 8 | Closes # 9 | 10 | ## Type of Change 11 | 12 | 13 | - [ ] Bug fix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | - [ ] Documentation update 17 | - [ ] Code refactoring 18 | - [ ] Performance improvement 19 | - [ ] Test improvement 20 | 21 | ## Changes Made 22 | 23 | 24 | - 25 | - 26 | - 27 | 28 | ## Testing 29 | 30 | 31 | - [ ] Tests added/updated for the changes 32 | - [ ] All existing tests pass 33 | - [ ] New tests pass 34 | - [ ] Manual testing performed 35 | 36 | ## Code Quality 37 | 38 | 39 | - [ ] Code follows the project's style guidelines 40 | - [ ] Self-review of code performed 41 | - [ ] Code is properly documented 42 | - [ ] No unnecessary debug code or comments left 43 | 44 | ## Screenshots (if applicable) 45 | 46 | 47 | 48 | ## Additional Notes 49 | 50 | 51 | 52 | ## Checklist 53 | 54 | - [ ] I have read the [CONTRIBUTING.md](../CONTRIBUTING.md) guide 55 | - [ ] My code follows the project's coding standards 56 | - [ ] I have performed a self-review of my code 57 | - [ ] I have commented my code, particularly in hard-to-understand areas 58 | - [ ] I have made corresponding changes to the documentation 59 | - [ ] My changes generate no new warnings 60 | - [ ] I have added tests that prove my fix is effective or that my feature works 61 | - [ ] New and existing unit tests pass locally with my changes -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # Configuration des labels pour Laravel Chronotrace 2 | # Ce fichier définit tous les labels utilisés pour les issues et PR 3 | 4 | # Scope - Domaines fonctionnels 5 | - name: "scope:docs" 6 | description: "Documentation: markdowns, examples, readme, etc." 7 | color: "0075ca" 8 | 9 | - name: "scope:commands" 10 | description: "Related to the Chronotrace commands system and command definitions." 11 | color: "0052cc" 12 | 13 | - name: "scope:config" 14 | description: "Configuration files, chronotrace.php settings, and options." 15 | color: "17a2b8" 16 | 17 | - name: "scope:cli" 18 | description: "Command-line interface, artisan commands, and console output." 19 | color: "28a745" 20 | 21 | - name: "scope:tests" 22 | description: "Unit, integration, or feature testing coverage." 23 | color: "0e8a16" 24 | 25 | - name: "scope:tests" 26 | description: "TDD test management, draft tests, and test promotion." 27 | color: "0e8a16" 28 | 29 | # Status - État des issues 30 | - name: "status:blocked" 31 | description: "Blocked by another issue or dependency." 32 | color: "d93f0b" 33 | 34 | - name: "status:discussion" 35 | description: "Under discussion — needs brainstorming or user input." 36 | color: "d4c5f9" 37 | 38 | - name: "status:ready" 39 | description: "Ready to be worked on." 40 | color: "0e8a16" 41 | 42 | - name: "status:wip" 43 | description: "Work in progress." 44 | color: "fbca04" 45 | 46 | # Type - Types d'issues 47 | - name: "type:bug" 48 | description: "A confirmed or suspected issue causing incorrect behavior." 49 | color: "d73a4a" 50 | 51 | - name: "type:enhancement" 52 | description: "An improvement to an existing feature or internal logic." 53 | color: "a2eeef" 54 | 55 | - name: "type:feature" 56 | description: "A new user-facing capability or module." 57 | color: "84b6eb" 58 | 59 | - name: "type:refactor" 60 | description: "Code restructuring without changing behavior." 61 | color: "fef2c0" 62 | 63 | - name: "type:security" 64 | description: "Security vulnerability or security-related improvement." 65 | color: "ff6b6b" 66 | -------------------------------------------------------------------------------- /src/Commands/ListCommand.php: -------------------------------------------------------------------------------- 1 | option('limit'); 18 | $showFullId = (bool) $this->option('full-id'); 19 | 20 | $this->info('Listing stored traces...'); 21 | 22 | try { 23 | $traces = $storage->list(); 24 | 25 | if ($traces === []) { 26 | $this->warn('No traces found.'); 27 | 28 | return Command::SUCCESS; 29 | } 30 | 31 | $this->table( 32 | ['Trace ID', 'Size', 'Created At'], 33 | array_slice(array_map(function (mixed $trace) use ($showFullId): array { 34 | if (! is_array($trace)) { 35 | return ['Unknown', 'Unknown', 'Unknown']; 36 | } 37 | 38 | $timestamp = is_numeric($trace['created_at']) ? (int) $trace['created_at'] : time(); 39 | $traceId = isset($trace['trace_id']) && is_string($trace['trace_id']) ? $trace['trace_id'] : 'unknown'; 40 | 41 | return [ 42 | $showFullId ? $traceId : substr($traceId, 0, 8) . '...', 43 | number_format($trace['size']) . ' bytes', 44 | date('Y-m-d H:i:s', $timestamp), 45 | ]; 46 | }, $traces), 0, $limit) 47 | ); 48 | 49 | $total = count($traces); 50 | $this->info("Showing {$limit} of {$total} traces."); 51 | } catch (Exception $e) { 52 | $this->error("Failed to list traces: {$e->getMessage()}"); 53 | 54 | return Command::FAILURE; 55 | } 56 | 57 | return Command::SUCCESS; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Models/TraceResponse.php: -------------------------------------------------------------------------------- 1 | $headers 14 | * @param array $cookies 15 | */ 16 | public function __construct( 17 | public int $status, 18 | public array $headers, 19 | public string $content, 20 | public float $duration, 21 | public int $memoryUsage, 22 | public float $timestamp, 23 | public ?string $exception, 24 | public array $cookies, 25 | ) {} 26 | 27 | /** 28 | * @return array 29 | */ 30 | public function toArray(): array 31 | { 32 | return [ 33 | 'status' => $this->status, 34 | 'headers' => $this->headers, 35 | 'content' => $this->content, 36 | 'duration' => $this->duration, 37 | 'memory_usage' => $this->memoryUsage, 38 | 'timestamp' => $this->timestamp, 39 | 'exception' => $this->exception, 40 | 'cookies' => $this->cookies, 41 | ]; 42 | } 43 | 44 | /** 45 | * @param array $data 46 | */ 47 | public static function fromArray(array $data): self 48 | { 49 | return new self( 50 | status: is_numeric($data['status'] ?? 200) ? (int) $data['status'] : 200, 51 | headers: is_array($data['headers'] ?? []) ? $data['headers'] ?? [] : [], 52 | content: $data['content'] ?? '', 53 | duration: is_numeric($data['duration'] ?? 0.0) ? (float) $data['duration'] : 0.0, 54 | memoryUsage: is_numeric($data['memory_usage'] ?? 0) ? (int) $data['memory_usage'] : 0, 55 | timestamp: is_numeric($data['timestamp'] ?? 0.0) ? (float) $data['timestamp'] : 0.0, 56 | exception: isset($data['exception']) && is_string($data['exception']) ? $data['exception'] : null, 57 | cookies: is_array($data['cookies'] ?? []) ? $data['cookies'] ?? [] : [], 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Feature/Commands/MiddlewareTestCommandTest.php: -------------------------------------------------------------------------------- 1 | artisan(MiddlewareTestCommand::class) 8 | ->expectsOutput('🧪 Testing ChronoTrace Middleware Installation') 9 | ->assertExitCode(0); 10 | }); 11 | 12 | it('reports configuration status', function (): void { 13 | config(['chronotrace.enabled' => true]); 14 | config(['chronotrace.mode' => 'always']); 15 | config(['chronotrace.debug' => true]); 16 | 17 | $this->artisan(MiddlewareTestCommand::class) 18 | ->expectsOutput('📋 Configuration Check:') 19 | ->expectsOutputToContain('chronotrace.enabled: true') 20 | ->expectsOutputToContain('chronotrace.mode: always') 21 | ->expectsOutputToContain('chronotrace.debug: true') 22 | ->assertExitCode(0); 23 | }); 24 | 25 | it('can instantiate middleware class', function (): void { 26 | $this->artisan(MiddlewareTestCommand::class) 27 | ->expectsOutputToContain('Middleware class can be instantiated') 28 | ->assertExitCode(0); 29 | }); 30 | 31 | it('provides recommendations when chronotrace is disabled', function (): void { 32 | config(['chronotrace.enabled' => false]); 33 | 34 | $this->artisan(MiddlewareTestCommand::class) 35 | ->expectsOutputToContain('Enable ChronoTrace: Set CHRONOTRACE_ENABLED=true') 36 | ->assertExitCode(0); 37 | }); 38 | 39 | it('provides recommendations when debug is disabled', function (): void { 40 | config(['chronotrace.debug' => false]); 41 | 42 | $this->artisan(MiddlewareTestCommand::class) 43 | ->expectsOutputToContain('Enable debug mode: Set CHRONOTRACE_DEBUG=true') 44 | ->assertExitCode(0); 45 | }); 46 | 47 | it('can simulate request processing', function (): void { 48 | config(['chronotrace.enabled' => true]); 49 | 50 | $this->artisan(MiddlewareTestCommand::class) 51 | ->expectsOutputToContain('Simulating GET /test request') 52 | ->expectsOutputToContain('Middleware processed request successfully') 53 | ->assertExitCode(0); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/Models/TraceContext.php: -------------------------------------------------------------------------------- 1 | $config 14 | * @param array $env_vars 15 | * @param array $packages 16 | * @param array $middlewares 17 | * @param array $providers 18 | */ 19 | public function __construct( 20 | public readonly string $laravel_version, 21 | public readonly string $php_version, 22 | public readonly array $config, 23 | public readonly array $env_vars, 24 | public readonly string $git_commit = '', 25 | public readonly string $branch = '', 26 | public readonly array $packages = [], 27 | public readonly array $middlewares = [], 28 | public readonly array $providers = [], 29 | ) {} 30 | 31 | public function toArray(): array 32 | { 33 | return [ 34 | 'laravel_version' => $this->laravel_version, 35 | 'php_version' => $this->php_version, 36 | 'config' => $this->config, 37 | 'env_vars' => $this->env_vars, 38 | 'git_commit' => $this->git_commit, 39 | 'branch' => $this->branch, 40 | 'packages' => $this->packages, 41 | 'middlewares' => $this->middlewares, 42 | 'providers' => $this->providers, 43 | ]; 44 | } 45 | 46 | /** 47 | * @param array $data 48 | */ 49 | public static function fromArray(array $data): self 50 | { 51 | return new self( 52 | laravel_version: $data['laravel_version'], 53 | php_version: $data['php_version'], 54 | config: is_array($data['config'] ?? []) ? $data['config'] ?? [] : [], 55 | env_vars: is_array($data['env_vars'] ?? []) ? $data['env_vars'] ?? [] : [], 56 | git_commit: $data['git_commit'] ?? '', 57 | branch: $data['branch'] ?? '', 58 | packages: is_array($data['packages'] ?? []) ? $data['packages'] ?? [] : [], 59 | middlewares: is_array($data['middlewares'] ?? []) ? $data['middlewares'] ?? [] : [], 60 | providers: is_array($data['providers'] ?? []) ? $data['providers'] ?? [] : [], 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Listeners/QueueEventListener.php: -------------------------------------------------------------------------------- 1 | traceRecorder->addCapturedData('jobs', [ 31 | 'type' => 'job_processing', 32 | 'job_name' => $event->job->resolveName(), 33 | 'queue' => $event->job->getQueue(), 34 | 'connection' => $event->connectionName, 35 | 'attempts' => $event->job->attempts(), 36 | 'timestamp' => microtime(true), 37 | ]); 38 | } 39 | 40 | /** 41 | * Capture la fin de traitement d'un job 42 | */ 43 | public function handleJobProcessed(JobProcessed $event): void 44 | { 45 | if (! config('chronotrace.capture.jobs', true)) { 46 | return; 47 | } 48 | 49 | $this->traceRecorder->addCapturedData('jobs', [ 50 | 'type' => 'job_processed', 51 | 'job_name' => $event->job->resolveName(), 52 | 'queue' => $event->job->getQueue(), 53 | 'connection' => $event->connectionName, 54 | 'timestamp' => microtime(true), 55 | ]); 56 | } 57 | 58 | /** 59 | * Capture les échecs de jobs 60 | */ 61 | public function handleJobFailed(JobFailed $event): void 62 | { 63 | if (! config('chronotrace.capture.jobs', true)) { 64 | return; 65 | } 66 | 67 | $this->traceRecorder->addCapturedData('jobs', [ 68 | 'type' => 'job_failed', 69 | 'job_name' => $event->job->resolveName(), 70 | 'queue' => $event->job->getQueue(), 71 | 'connection' => $event->connectionName, 72 | 'exception' => $event->exception->getMessage(), 73 | 'timestamp' => microtime(true), 74 | ]); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Models/TraceRequest.php: -------------------------------------------------------------------------------- 1 | $headers 14 | * @param array $query 15 | * @param array $input 16 | * @param array $files 17 | * @param array|null $user 18 | * @param array $session 19 | */ 20 | public function __construct( 21 | public string $method, 22 | public string $url, 23 | public array $headers, 24 | public array $query, 25 | public array $input, 26 | public array $files, 27 | public ?array $user, 28 | public array $session, 29 | public string $userAgent, 30 | public string $ip, 31 | public float $timestamp, 32 | ) {} 33 | 34 | /** 35 | * @return array 36 | */ 37 | public function toArray(): array 38 | { 39 | return [ 40 | 'method' => $this->method, 41 | 'url' => $this->url, 42 | 'headers' => $this->headers, 43 | 'query' => $this->query, 44 | 'input' => $this->input, 45 | 'files' => $this->files, 46 | 'user' => $this->user, 47 | 'session' => $this->session, 48 | 'user_agent' => $this->userAgent, 49 | 'ip' => $this->ip, 50 | 'timestamp' => $this->timestamp, 51 | ]; 52 | } 53 | 54 | /** 55 | * @param array $data 56 | */ 57 | public static function fromArray(array $data): self 58 | { 59 | return new self( 60 | method: $data['method'] ?? '', 61 | url: $data['url'] ?? '', 62 | headers: is_array($data['headers'] ?? []) ? $data['headers'] ?? [] : [], 63 | query: is_array($data['query'] ?? []) ? $data['query'] ?? [] : [], 64 | input: is_array($data['input'] ?? []) ? $data['input'] ?? [] : [], 65 | files: is_array($data['files'] ?? []) ? $data['files'] ?? [] : [], 66 | user: isset($data['user']) && is_array($data['user']) ? $data['user'] : null, 67 | session: is_array($data['session'] ?? []) ? $data['session'] ?? [] : [], 68 | userAgent: $data['user_agent'] ?? '', 69 | ip: $data['ip'] ?? '', 70 | timestamp: is_numeric($data['timestamp'] ?? 0) ? (float) $data['timestamp'] : 0.0, 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/Unit/Commands/TestInternalCommandTest.php: -------------------------------------------------------------------------------- 1 | true]); 7 | }); 8 | 9 | it('can execute test internal command', function (): void { 10 | $this->artisan(TestInternalCommand::class) 11 | ->expectsOutputToContain('🧪 Testing ChronoTrace with internal Laravel operations...') 12 | ->expectsOutputToContain('🗄️ Testing database operations...') 13 | ->expectsOutputToContain('💾 Testing cache operations...') 14 | ->expectsOutputToContain('📡 Testing custom events...') 15 | ->expectsOutputToContain('✅ Internal operations test completed!') 16 | ->assertExitCode(0); 17 | }); 18 | 19 | it('respects chronotrace disabled configuration', function (): void { 20 | config(['chronotrace.enabled' => false]); 21 | 22 | $this->artisan(TestInternalCommand::class) 23 | ->expectsOutputToContain('⚠️ ChronoTrace is disabled') 24 | ->assertExitCode(1); 25 | }); 26 | 27 | it('can run with specific options', function (): void { 28 | $this->artisan(TestInternalCommand::class, ['--with-db' => true]) 29 | ->expectsOutputToContain('🗄️ Testing database operations...') 30 | ->expectsOutputToContain('✅ Internal operations test completed!') 31 | ->assertExitCode(0); 32 | }); 33 | 34 | it('can run cache operations only', function (): void { 35 | $this->artisan(TestInternalCommand::class, ['--with-cache' => true]) 36 | ->expectsOutputToContain('💾 Testing cache operations...') 37 | ->expectsOutputToContain('✅ Internal operations test completed!') 38 | ->assertExitCode(0); 39 | }); 40 | 41 | it('can run events only', function (): void { 42 | $this->artisan(TestInternalCommand::class, ['--with-events' => true]) 43 | ->expectsOutputToContain('📡 Testing custom events...') 44 | ->expectsOutputToContain('✅ Internal operations test completed!') 45 | ->assertExitCode(0); 46 | }); 47 | 48 | it('has correct command signature', function (): void { 49 | $command = new TestInternalCommand; 50 | expect($command->getName())->toBe('chronotrace:test-internal'); 51 | expect($command->getDescription())->toContain('Test ChronoTrace with internal Laravel operations'); 52 | }); 53 | 54 | it('provides helpful usage instructions', function (): void { 55 | $this->artisan(TestInternalCommand::class) 56 | ->expectsOutputToContain('php artisan chronotrace:replay') 57 | ->expectsOutputToContain('Trace ID:') 58 | ->assertExitCode(0); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/Unit/Listeners/QueueEventListenerTest.php: -------------------------------------------------------------------------------- 1 | true]); 11 | 12 | $mockRecorder = Mockery::mock(TraceRecorder::class); 13 | $mockRecorder->shouldReceive('addCapturedData') 14 | ->once() 15 | ->with('jobs', Mockery::on(fn ($data): bool => $data['type'] === 'job_processing' 16 | && isset($data['job_name']) 17 | && isset($data['queue']))); 18 | 19 | $listener = new QueueEventListener($mockRecorder); 20 | 21 | $mockJob = Mockery::mock(Job::class); 22 | $mockJob->shouldReceive('resolveName')->andReturn('App\\Jobs\\SendEmailJob'); 23 | $mockJob->shouldReceive('getQueue')->andReturn('default'); 24 | $mockJob->shouldReceive('attempts')->andReturn(1); 25 | 26 | $event = new JobProcessing('connection', $mockJob); 27 | $listener->handleJobProcessing($event); 28 | }); 29 | 30 | it('captures job processed events', function (): void { 31 | config(['chronotrace.capture.jobs' => true]); 32 | 33 | $mockRecorder = Mockery::mock(TraceRecorder::class); 34 | $mockRecorder->shouldReceive('addCapturedData') 35 | ->once() 36 | ->with('jobs', Mockery::on(fn ($data): bool => $data['type'] === 'job_processed' 37 | && isset($data['job_name']) 38 | && isset($data['queue']))); 39 | 40 | $listener = new QueueEventListener($mockRecorder); 41 | 42 | $mockJob = Mockery::mock(Job::class); 43 | $mockJob->shouldReceive('resolveName')->andReturn('App\\Jobs\\SendEmailJob'); 44 | $mockJob->shouldReceive('getQueue')->andReturn('default'); 45 | 46 | $event = new JobProcessed('connection', $mockJob); 47 | $listener->handleJobProcessed($event); 48 | }); 49 | 50 | it('does not capture queue events when disabled', function (): void { 51 | config(['chronotrace.capture.jobs' => false]); 52 | 53 | $mockRecorder = Mockery::mock(TraceRecorder::class); 54 | $mockRecorder->shouldNotReceive('addCapturedData'); 55 | 56 | $listener = new QueueEventListener($mockRecorder); 57 | 58 | $mockJob = Mockery::mock(Job::class); 59 | $mockJob->shouldReceive('resolveName')->andReturn('App\\Jobs\\SendEmailJob'); 60 | $mockJob->shouldReceive('getQueue')->andReturn('default'); 61 | 62 | $event = new JobProcessing('connection', $mockJob); 63 | $listener->handleJobProcessing($event); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/Unit/Listeners/CacheEventListenerTest.php: -------------------------------------------------------------------------------- 1 | true]); 10 | 11 | $mockRecorder = Mockery::mock(TraceRecorder::class); 12 | $mockRecorder->shouldReceive('addCapturedData') 13 | ->once() 14 | ->with('cache', Mockery::on(fn ($data): bool => $data['type'] === 'hit' 15 | && isset($data['key']) 16 | && isset($data['value_size']))); 17 | 18 | $listener = new CacheEventListener($mockRecorder); 19 | 20 | // CacheHit($storeName, $key, $value, $tags) 21 | $event = new CacheHit('default', 'test_key', 'test_value', []); 22 | $listener->handleCacheHit($event); 23 | }); 24 | 25 | it('captures cache miss events', function (): void { 26 | config(['chronotrace.capture.cache' => true]); 27 | 28 | $mockRecorder = Mockery::mock(TraceRecorder::class); 29 | $mockRecorder->shouldReceive('addCapturedData') 30 | ->once() 31 | ->with('cache', Mockery::on(fn ($data): bool => $data['type'] === 'miss' 32 | && isset($data['key']))); 33 | 34 | $listener = new CacheEventListener($mockRecorder); 35 | 36 | // CacheMissed($storeName, $key, $tags) 37 | $event = new CacheMissed('default', 'missing_key', []); 38 | $listener->handleCacheMissed($event); 39 | }); 40 | it('scrubs sensitive cache keys', function (): void { 41 | config(['chronotrace.capture.cache' => true]); 42 | 43 | $mockRecorder = Mockery::mock(TraceRecorder::class); 44 | $mockRecorder->shouldReceive('addCapturedData') 45 | ->once() 46 | ->with('cache', Mockery::on(fn ($data): bool => $data['type'] === 'hit' 47 | && $data['key'] === '[SCRUBBED_CACHE_KEY]' // La clé devrait être scrubbed 48 | && isset($data['value_size']))); 49 | 50 | $listener = new CacheEventListener($mockRecorder); 51 | 52 | // CacheHit($storeName, $key, $value, $tags) 53 | $event = new CacheHit('default', 'user_123_token', 'secret_token_value', []); 54 | $listener->handleCacheHit($event); 55 | }); 56 | 57 | it('does not capture cache events when disabled', function (): void { 58 | config(['chronotrace.capture.cache' => false]); 59 | 60 | $mockRecorder = Mockery::mock(TraceRecorder::class); 61 | $mockRecorder->shouldNotReceive('addCapturedData'); 62 | 63 | $listener = new CacheEventListener($mockRecorder); 64 | 65 | // CacheHit($storeName, $key, $value, $tags) 66 | $event = new CacheHit('default', 'test_key', 'test_value', []); 67 | $listener->handleCacheHit($event); 68 | }); 69 | -------------------------------------------------------------------------------- /src/Listeners/DatabaseEventListener.php: -------------------------------------------------------------------------------- 1 | traceRecorder->addCapturedData('database', [ 32 | 'type' => 'query', 33 | 'sql' => $event->sql, 34 | 'bindings' => $event->bindings, 35 | 'time' => $event->time, 36 | 'connection' => $event->connectionName, 37 | 'timestamp' => microtime(true), 38 | ]); 39 | } 40 | 41 | /** 42 | * Capture le début des transactions 43 | */ 44 | public function handleTransactionBeginning(TransactionBeginning $event): void 45 | { 46 | if (! config('chronotrace.capture.database', true)) { 47 | return; 48 | } 49 | 50 | $this->traceRecorder->addCapturedData('database', [ 51 | 'type' => 'transaction_begin', 52 | 'connection' => $event->connectionName, 53 | 'timestamp' => microtime(true), 54 | ]); 55 | } 56 | 57 | /** 58 | * Capture les commits de transactions 59 | */ 60 | public function handleTransactionCommitted(TransactionCommitted $event): void 61 | { 62 | if (! config('chronotrace.capture.database', true)) { 63 | return; 64 | } 65 | 66 | $this->traceRecorder->addCapturedData('database', [ 67 | 'type' => 'transaction_commit', 68 | 'connection' => $event->connectionName, 69 | 'timestamp' => microtime(true), 70 | ]); 71 | } 72 | 73 | /** 74 | * Capture les rollbacks de transactions 75 | */ 76 | public function handleTransactionRolledBack(TransactionRolledBack $event): void 77 | { 78 | if (! config('chronotrace.capture.database', true)) { 79 | return; 80 | } 81 | 82 | $this->traceRecorder->addCapturedData('database', [ 83 | 'type' => 'transaction_rollback', 84 | 'connection' => $event->connectionName, 85 | 'timestamp' => microtime(true), 86 | ]); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | # Manual release trigger 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version: 8 | description: 'Version to release (e.g., 1.0.0)' 9 | required: true 10 | type: string 11 | release_notes: 12 | description: 'Release notes' 13 | required: false 14 | type: string 15 | 16 | permissions: 17 | contents: write 18 | pull-requests: read 19 | 20 | jobs: 21 | manual-release: 22 | runs-on: ubuntu-latest 23 | if: github.actor == 'grazulex' # Only allow grazulex to trigger releases 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: 8.4 35 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 36 | coverage: none 37 | 38 | - name: Setup problem matchers 39 | run: | 40 | echo "::add-matcher::${{ runner.tool_cache }}/php.json" 41 | echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 42 | 43 | - name: Install dependencies 44 | run: | 45 | composer require "laravel/framework:12.*" "orchestra/testbench:10.*" --no-interaction --no-update 46 | composer update --prefer-stable --prefer-dist --no-interaction 47 | 48 | - name: List Installed Dependencies 49 | run: composer show -D 50 | 51 | - name: Execute tests 52 | run: | 53 | vendor/bin/pest 54 | vendor/bin/phpstan analyse --memory-limit=2G --configuration=phpstan.neon 55 | 56 | - name: Create Git tag 57 | run: | 58 | git config user.name "Jean-Marc Strauven" 59 | git config user.email "jms@grazulex.be" 60 | 61 | # Check if tag already exists 62 | if git rev-parse "v${{ github.event.inputs.version }}" >/dev/null 2>&1; then 63 | echo "❌ Tag v${{ github.event.inputs.version }} already exists" 64 | exit 1 65 | fi 66 | 67 | # Create and push tag 68 | git tag -a "v${{ github.event.inputs.version }}" -m "Release v${{ github.event.inputs.version }}" 69 | git push origin "v${{ github.event.inputs.version }}" 70 | 71 | - name: Create GitHub Release 72 | uses: softprops/action-gh-release@v1 73 | with: 74 | tag_name: "v${{ github.event.inputs.version }}" 75 | name: "Release v${{ github.event.inputs.version }}" 76 | body: ${{ github.event.inputs.release_notes }} 77 | draft: false 78 | prerelease: false 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | - vendor/larastan/larastan/extension.neon 4 | 5 | parameters: 6 | level: max 7 | paths: 8 | - src 9 | 10 | # Custom rules 11 | reportUnmatchedIgnoredErrors: false 12 | 13 | # Exclude patterns 14 | excludePaths: 15 | - tests/Pest.php 16 | 17 | # Ignore specific rules for config files 18 | ignoreErrors: 19 | - 20 | message: '#Called .env. outside of the config directory#' 21 | path: src/Config/chronotrace.php 22 | - 23 | message: '#Parameter .* expects array, array, array\|null, array, mixed given#' 41 | paths: 42 | - src/Services/* 43 | - src/Storage/* 44 | - 45 | message: '#Parameter .* expects array, array.*given#' 46 | paths: 47 | - src/Services/* 48 | - src/Storage/* 49 | - 50 | message: '#Parameter \$user of class .* expects array\|null, array\|null given#' 51 | path: src/Services/TraceRecorder.php 52 | - 53 | message: '#Parameter \$env_vars of class .* expects array, array given#' 54 | path: src/Services/TraceRecorder.php 55 | - 56 | message: '#Cannot call method (isDir|getRealPath) on mixed#' 57 | path: src/Storage/TraceStorage.php 58 | - 59 | message: '#Property .* does not accept array#' 60 | path: src/Services/PIIScrubber.php 61 | - 62 | message: '#Parameter \#1 \$pattern of function preg_replace expects .*, mixed given#' 63 | path: src/Services/PIIScrubber.php 64 | - 65 | message: '#Call to function is_string\(\) with string will always evaluate to true#' 66 | path: src/Services/PIIScrubber.php 67 | - 68 | message: '#Property .* is never read, only written#' 69 | path: src/Storage/TraceStorage.php 70 | - 71 | message: '#Parameter \#1 \$headers .* expects array, array given#' 72 | paths: 73 | - src/Listeners/* 74 | -------------------------------------------------------------------------------- /tests/Integration/EventListenersIntegrationTest.php: -------------------------------------------------------------------------------- 1 | set('chronotrace.enabled', true); 26 | $app['config']->set('chronotrace.capture.database', true); 27 | $app['config']->set('chronotrace.capture.cache', true); 28 | $app['config']->set('chronotrace.capture.http', true); 29 | $app['config']->set('chronotrace.capture.jobs', true); 30 | } 31 | 32 | public function test_registers_all_event_listeners_when_package_is_enabled(): void 33 | { 34 | // Vérifier que les listeners sont enregistrés dans le container 35 | $this->assertTrue($this->app->bound(DatabaseEventListener::class)); 36 | $this->assertTrue($this->app->bound(CacheEventListener::class)); 37 | $this->assertTrue($this->app->bound(HttpEventListener::class)); 38 | $this->assertTrue($this->app->bound(QueueEventListener::class)); 39 | } 40 | 41 | public function test_has_event_listeners_registered_for_laravel_events(): void 42 | { 43 | $dispatcher = $this->app['events']; 44 | 45 | // Vérifier que les événements ont des listeners 46 | $queryListeners = $dispatcher->getListeners(QueryExecuted::class); 47 | $cacheListeners = $dispatcher->getListeners(CacheHit::class); 48 | 49 | $this->assertNotEmpty($queryListeners); 50 | $this->assertNotEmpty($cacheListeners); 51 | } 52 | 53 | public function test_respects_capture_configuration_flags(): void 54 | { 55 | $this->app['config']->set('chronotrace.capture.database', true); 56 | $this->app['config']->set('chronotrace.capture.cache', false); 57 | $this->app['config']->set('chronotrace.capture.http', true); 58 | $this->app['config']->set('chronotrace.capture.jobs', false); 59 | 60 | $this->assertTrue(config('chronotrace.capture.database')); 61 | $this->assertFalse(config('chronotrace.capture.cache')); 62 | $this->assertTrue(config('chronotrace.capture.http')); 63 | $this->assertFalse(config('chronotrace.capture.jobs')); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Unit/ResponseTypesTest.php: -------------------------------------------------------------------------------- 1 | traceRecorder = app(TraceRecorder::class); 30 | } 31 | 32 | public function test_finish_capture_with_json_response(): void 33 | { 34 | $request = Request::create('/test', 'GET'); 35 | $traceId = $this->traceRecorder->startCapture($request); 36 | $response = new JsonResponse(['data' => 'test']); 37 | 38 | // Ne doit pas lever de TypeError 39 | $this->traceRecorder->finishCapture($traceId, $response, 0.1, 1024); 40 | 41 | $this->assertTrue(true); 42 | } 43 | 44 | public function test_finish_capture_with_redirect_response(): void 45 | { 46 | $request = Request::create('/test', 'GET'); 47 | $traceId = $this->traceRecorder->startCapture($request); 48 | $response = new RedirectResponse('https://example.com'); 49 | 50 | // Ne doit pas lever de TypeError 51 | $this->traceRecorder->finishCapture($traceId, $response, 0.1, 1024); 52 | 53 | $this->assertTrue(true); 54 | } 55 | 56 | public function test_finish_capture_with_regular_response(): void 57 | { 58 | $request = Request::create('/test', 'GET'); 59 | $traceId = $this->traceRecorder->startCapture($request); 60 | $response = new Response('Hello World'); 61 | 62 | // Ne doit pas lever de TypeError 63 | $this->traceRecorder->finishCapture($traceId, $response, 0.1, 1024); 64 | 65 | $this->assertTrue(true); 66 | } 67 | 68 | public function test_finish_capture_with_custom_response(): void 69 | { 70 | $request = Request::create('/test', 'GET'); 71 | $traceId = $this->traceRecorder->startCapture($request); 72 | 73 | // Test avec une réponse custom qui hérite de Symfony\Component\HttpFoundation\Response 74 | $response = new class extends \Symfony\Component\HttpFoundation\Response 75 | { 76 | public function __construct() 77 | { 78 | parent::__construct('Custom Response', 200); 79 | } 80 | }; 81 | 82 | // Ne doit pas lever de TypeError 83 | $this->traceRecorder->finishCapture($traceId, $response, 0.1, 1024); 84 | 85 | $this->assertTrue(true); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Unit/Listeners/DatabaseEventListenerTest.php: -------------------------------------------------------------------------------- 1 | true]); 12 | 13 | $mockRecorder = Mockery::mock(TraceRecorder::class); 14 | $mockRecorder->shouldReceive('addCapturedData') 15 | ->once() 16 | ->with('database', Mockery::on(fn ($data): bool => $data['type'] === 'query' 17 | && isset($data['sql']) 18 | && isset($data['time']))); 19 | 20 | // Créer le listener 21 | $listener = new DatabaseEventListener($mockRecorder); 22 | 23 | // Mock de connection 24 | $mockConnection = Mockery::mock(Connection::class); 25 | $mockConnection->shouldReceive('getName')->andReturn('mysql'); 26 | 27 | // Simuler un événement de requête 28 | $event = new QueryExecuted( 29 | 'SELECT * FROM users WHERE id = ?', 30 | [1], 31 | 123.45, 32 | $mockConnection 33 | ); 34 | 35 | // Exécuter le listener 36 | $listener->handleQueryExecuted($event); 37 | }); 38 | 39 | it('does not capture database events when disabled', function (): void { 40 | config(['chronotrace.capture.database' => false]); 41 | 42 | $mockRecorder = Mockery::mock(TraceRecorder::class); 43 | $mockRecorder->shouldNotReceive('addCapturedData'); 44 | 45 | $listener = new DatabaseEventListener($mockRecorder); 46 | 47 | // Mock de connection 48 | $mockConnection = Mockery::mock(Connection::class); 49 | $mockConnection->shouldReceive('getName')->andReturn('mysql'); 50 | 51 | $event = new QueryExecuted( 52 | 'SELECT * FROM users', 53 | [], 54 | 50, 55 | $mockConnection 56 | ); 57 | 58 | $listener->handleQueryExecuted($event); 59 | }); 60 | 61 | it('captures transaction events', function (): void { 62 | config(['chronotrace.capture.database' => true]); 63 | 64 | $mockRecorder = Mockery::mock(TraceRecorder::class); 65 | $mockRecorder->shouldReceive('addCapturedData') 66 | ->times(2) // Pour begin et commit 67 | ->with('database', Mockery::on(fn ($data): bool => in_array($data['type'], ['transaction_begin', 'transaction_commit']))); 68 | 69 | $listener = new DatabaseEventListener($mockRecorder); 70 | 71 | // Mock de connection 72 | $mockConnection = Mockery::mock(Connection::class); 73 | $mockConnection->shouldReceive('getName')->andReturn('mysql'); 74 | 75 | // Test transaction begin 76 | $beginEvent = new TransactionBeginning($mockConnection); 77 | $listener->handleTransactionBeginning($beginEvent); 78 | 79 | // Test transaction commit 80 | $commitEvent = new TransactionCommitted($mockConnection); 81 | $listener->handleTransactionCommitted($commitEvent); 82 | }); 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Laravel Chronotrace 2 | 3 | Thank you for your interest in contributing to Laravel Chronotrace! We welcome contributions from the community to help make this package even better. 4 | 5 | ## Code of Conduct 6 | 7 | By participating in this project, you are expected to uphold our code of conduct. Please be respectful and professional in all interactions. 8 | 9 | ## How to Contribute 10 | 11 | ### Reporting Bugs 12 | 13 | If you find a bug, please create an issue on GitHub with: 14 | - A clear description of the problem 15 | - Steps to reproduce the issue 16 | - Expected vs actual behavior 17 | - PHP and Laravel version information 18 | - Any relevant code samples 19 | 20 | ### Suggesting Features 21 | 22 | We welcome feature suggestions! Please create an issue with: 23 | - A clear description of the proposed feature 24 | - Use cases and benefits 25 | - Any implementation details you have in mind 26 | 27 | ### Pull Requests 28 | 29 | 1. Fork the repository 30 | 2. Create a feature branch: `git checkout -b feature/amazing-feature` 31 | 3. Make your changes 32 | 4. Add tests for new functionality 33 | 5. Ensure all tests pass: `composer run full` 34 | 6. Commit your changes: `git commit -m 'Add amazing feature'` 35 | 7. Push to the branch: `git push origin feature/amazing-feature` 36 | 8. Open a Pull Request 37 | 38 | ## Development Setup 39 | 40 | 1. Clone the repository: 41 | ```bash 42 | git clone https://github.com/grazulex/laravel-chronotrace.git 43 | cd laravel-chronotrace 44 | ``` 45 | 46 | 2. Install dependencies: 47 | ```bash 48 | composer install 49 | ``` 50 | 51 | 3. Run tests to ensure everything works: 52 | ```bash 53 | composer run full 54 | ``` 55 | 56 | ## Code Standards 57 | 58 | This project follows Laravel coding standards and uses several tools to maintain code quality: 59 | 60 | - **Laravel Pint** for code formatting 61 | - **PHPStan** for static analysis 62 | - **Rector** for automated refactoring 63 | - **Pest** for testing 64 | 65 | Before submitting a PR, please run: 66 | ```bash 67 | composer run full 68 | ``` 69 | 70 | ## Writing Tests 71 | 72 | - Write tests for all new functionality 73 | - Use descriptive test names 74 | - Follow the Arrange-Act-Assert pattern 75 | - Add both unit and feature tests when appropriate 76 | 77 | Example test: 78 | ```php 79 | toBe('expected output'); 90 | }); 91 | ``` 92 | 93 | ## Documentation 94 | 95 | - Update README.md for new features 96 | - Add inline documentation for public methods 97 | - Include usage examples for new functionality 98 | 99 | ## Questions? 100 | 101 | If you have questions about contributing, feel free to: 102 | - Open an issue for discussion 103 | - Start a discussion in the GitHub Discussions tab 104 | - Contact the maintainers 105 | 106 | Thank you for contributing! 🚀 107 | -------------------------------------------------------------------------------- /src/Services/PIIScrubber.php: -------------------------------------------------------------------------------- 1 | */ 13 | private readonly array $scrubFields; 14 | 15 | /** @var array */ 16 | private readonly array $scrubPatterns; 17 | 18 | public function __construct() 19 | { 20 | $scrubFields = config('chronotrace.scrub', [ 21 | 'password', 'token', 'authorization', 'credit_card', 'ssn', 'email', 22 | ]); 23 | $this->scrubFields = is_array($scrubFields) ? $scrubFields : []; 24 | 25 | $scrubPatterns = config('chronotrace.scrub_patterns', [ 26 | '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', // emails 27 | '/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/', // cartes de crédit 28 | ]); 29 | $this->scrubPatterns = is_array($scrubPatterns) ? $scrubPatterns : []; 30 | } 31 | 32 | /** 33 | * Nettoie un tableau de données sensibles 34 | * 35 | * @param array $data 36 | * 37 | * @return array 38 | */ 39 | public function scrubArray(array $data): array 40 | { 41 | if (! config('chronotrace.scrub.enabled', true)) { 42 | return $data; 43 | } 44 | 45 | return $this->scrubArrayRecursive($data); 46 | } 47 | 48 | /** 49 | * Nettoie une chaîne de caractères 50 | */ 51 | public function scrubString(string $content): string 52 | { 53 | if (! config('chronotrace.scrub.enabled', true)) { 54 | return $content; 55 | } 56 | 57 | foreach ($this->scrubPatterns as $pattern) { 58 | $result = preg_replace($pattern, '[SCRUBBED]', $content); 59 | if ($result !== null) { 60 | $content = $result; 61 | } 62 | } 63 | 64 | return $content; 65 | } 66 | 67 | /** 68 | * @param array $data 69 | * 70 | * @return array 71 | */ 72 | private function scrubArrayRecursive(array $data): array 73 | { 74 | $scrubbed = []; 75 | 76 | foreach ($data as $key => $value) { 77 | if (is_array($value)) { 78 | $scrubbed[$key] = $this->scrubArrayRecursive($value); 79 | } elseif (is_string($key) && $this->shouldScrubField($key)) { 80 | $scrubbed[$key] = '[SCRUBBED]'; 81 | } else { 82 | $scrubbed[$key] = is_string($value) ? $this->scrubString($value) : $value; 83 | } 84 | } 85 | 86 | return $scrubbed; 87 | } 88 | 89 | private function shouldScrubField(string $fieldName): bool 90 | { 91 | $lowerFieldName = strtolower($fieldName); 92 | 93 | foreach ($this->scrubFields as $scrubField) { 94 | if (is_string($scrubField) && str_contains($lowerFieldName, strtolower($scrubField))) { 95 | return true; 96 | } 97 | } 98 | 99 | return false; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Display/Events/CacheEventDisplayer.php: -------------------------------------------------------------------------------- 1 | $options 18 | */ 19 | public function display(Command $command, TraceData $trace, array $options = []): void 20 | { 21 | $events = $this->getEventsByType($trace, 'cache'); 22 | 23 | if ($events === []) { 24 | return; 25 | } 26 | 27 | $command->warn('🗄️ CACHE EVENTS'); 28 | 29 | foreach ($events as $event) { 30 | if (! is_array($event)) { 31 | continue; 32 | } 33 | 34 | $type = $this->getStringValue($event, 'type', 'unknown'); 35 | $timestamp = $this->getTimestampFormatted($event); 36 | $key = $this->getStringValue($event, 'key', 'N/A'); 37 | $store = $this->getStringValue($event, 'store', 'default'); 38 | 39 | match ($type) { 40 | 'hit' => $command->line(" ✅ [{$timestamp}] Cache HIT: {$key} (store: {$store}, size: " . 41 | $this->getStringValue($event, 'value_size', 'N/A') . ' bytes)'), 42 | 'miss' => $command->line(" ❌ [{$timestamp}] Cache MISS: {$key} (store: {$store})"), 43 | 'write' => $command->line(" 💾 [{$timestamp}] Cache WRITE: {$key} (store: {$store})"), 44 | 'forget' => $command->line(" 🗑️ [{$timestamp}] Cache FORGET: {$key} (store: {$store})"), 45 | default => $command->line(" ❓ [{$timestamp}] Unknown cache event: {$type}"), 46 | }; 47 | } 48 | 49 | $command->newLine(); 50 | } 51 | 52 | public function getEventType(): string 53 | { 54 | return 'cache'; 55 | } 56 | 57 | public function canHandle(string $eventType): bool 58 | { 59 | return $eventType === 'cache'; 60 | } 61 | 62 | public function getSummary(array $events): array 63 | { 64 | $hits = 0; 65 | $misses = 0; 66 | $writes = 0; 67 | $forgets = 0; 68 | 69 | foreach ($events as $event) { 70 | if (! is_array($event)) { 71 | continue; 72 | } 73 | 74 | $type = $this->getStringValue($event, 'type', 'unknown'); 75 | 76 | match ($type) { 77 | 'hit' => $hits++, 78 | 'miss' => $misses++, 79 | 'write' => $writes++, 80 | 'forget' => $forgets++, 81 | default => null, // Ignore les types inconnus 82 | }; 83 | } 84 | 85 | $total = $hits + $misses; 86 | $hitRate = $total > 0 ? round(($hits / $total) * 100, 1) : 0; 87 | 88 | return [ 89 | 'hits' => $hits, 90 | 'misses' => $misses, 91 | 'writes' => $writes, 92 | 'forgets' => $forgets, 93 | 'hit_rate' => $hitRate, 94 | ]; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Unit/Listeners/HttpEventListenerTest.php: -------------------------------------------------------------------------------- 1 | true]); 12 | 13 | $mockRecorder = Mockery::mock(TraceRecorder::class); 14 | $mockRecorder->shouldReceive('addCapturedData') 15 | ->once() 16 | ->with('http', Mockery::on(fn ($data): bool => $data['type'] === 'request_sending' 17 | && isset($data['url']) 18 | && isset($data['method']))); 19 | 20 | $listener = new HttpEventListener($mockRecorder); 21 | 22 | $mockRequest = Mockery::mock(Request::class); 23 | $mockRequest->shouldReceive('url')->andReturn('https://api.example.com/users'); 24 | $mockRequest->shouldReceive('method')->andReturn('GET'); 25 | $mockRequest->shouldReceive('headers')->andReturn([]); 26 | $mockRequest->shouldReceive('body')->andReturn(''); 27 | 28 | $event = new RequestSending($mockRequest); 29 | $listener->handleRequestSending($event); 30 | }); 31 | 32 | it('captures HTTP response events', function (): void { 33 | config(['chronotrace.capture.http' => true]); 34 | 35 | $mockRecorder = Mockery::mock(TraceRecorder::class); 36 | $mockRecorder->shouldReceive('addCapturedData') 37 | ->once() 38 | ->with('http', Mockery::on(fn ($data): bool => $data['type'] === 'response_received' 39 | && isset($data['status']) 40 | && isset($data['url']))); 41 | 42 | $listener = new HttpEventListener($mockRecorder); 43 | 44 | $mockRequest = Mockery::mock(Request::class); 45 | $mockRequest->shouldReceive('url')->andReturn('https://api.example.com/users'); 46 | $mockRequest->shouldReceive('method')->andReturn('GET'); 47 | 48 | $mockResponse = Mockery::mock(Response::class); 49 | $mockResponse->shouldReceive('status')->andReturn(200); 50 | $mockResponse->shouldReceive('transferStats')->andReturn((object) ['total_time' => 0.5]); 51 | $mockResponse->shouldReceive('body')->andReturn('{"status":"ok"}'); 52 | $mockResponse->shouldReceive('headers')->andReturn([]); 53 | 54 | $event = new ResponseReceived($mockRequest, $mockResponse); 55 | $listener->handleResponseReceived($event); 56 | }); 57 | 58 | it('does not capture HTTP events when disabled', function (): void { 59 | config(['chronotrace.capture.http' => false]); 60 | 61 | $mockRecorder = Mockery::mock(TraceRecorder::class); 62 | $mockRecorder->shouldNotReceive('addCapturedData'); 63 | 64 | $listener = new HttpEventListener($mockRecorder); 65 | 66 | $mockRequest = Mockery::mock(Request::class); 67 | $mockRequest->shouldReceive('url')->andReturn('https://api.example.com/users'); 68 | $mockRequest->shouldReceive('method')->andReturn('GET'); 69 | $mockRequest->shouldReceive('headers')->andReturn([]); 70 | $mockRequest->shouldReceive('body')->andReturn(''); 71 | 72 | $event = new RequestSending($mockRequest); 73 | $listener->handleRequestSending($event); 74 | }); 75 | -------------------------------------------------------------------------------- /src/Display/Formatters/JsonOutputFormatter.php: -------------------------------------------------------------------------------- 1 | $trace->traceId, 20 | 'timestamp' => $trace->timestamp, 21 | 'environment' => $trace->environment, 22 | 'request' => [ 23 | 'method' => $trace->request->method, 24 | 'url' => $trace->request->url, 25 | 'headers' => $trace->request->headers, 26 | 'query' => $trace->request->query, 27 | 'input' => $trace->request->input, 28 | 'files' => $trace->request->files, 29 | 'user' => $trace->request->user, 30 | 'session' => $trace->request->session, 31 | 'user_agent' => $trace->request->userAgent, 32 | 'ip' => $trace->request->ip, 33 | ], 34 | 'response' => [ 35 | 'status' => $trace->response->status, 36 | 'headers' => $trace->response->headers, 37 | 'content' => $trace->response->content, 38 | 'duration' => $trace->response->duration, 39 | 'memory_usage' => $trace->response->memoryUsage, 40 | 'exception' => $trace->response->exception, 41 | 'cookies' => $trace->response->cookies, 42 | ], 43 | 'context' => [ 44 | 'laravel_version' => $trace->context->laravel_version, 45 | 'php_version' => $trace->context->php_version, 46 | 'config' => $trace->context->config, 47 | 'env_vars' => $trace->context->env_vars, 48 | 'git_commit' => $trace->context->git_commit, 49 | 'branch' => $trace->context->branch, 50 | 'packages' => $trace->context->packages, 51 | 'middlewares' => $trace->context->middlewares, 52 | 'providers' => $trace->context->providers, 53 | ], 54 | 'events' => [ 55 | 'database' => $trace->database, 56 | 'cache' => $trace->cache, 57 | 'http' => $trace->http, 58 | 'mail' => $trace->mail, 59 | 'notifications' => $trace->notifications, 60 | 'events' => $trace->events, 61 | 'jobs' => $trace->jobs, 62 | 'filesystem' => $trace->filesystem, 63 | ], 64 | ]; 65 | 66 | $json = json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 67 | 68 | if ($json === false) { 69 | throw new RuntimeException('Failed to encode trace as JSON'); 70 | } 71 | 72 | return $json; 73 | } 74 | 75 | public function getFormatType(): string 76 | { 77 | return 'json'; 78 | } 79 | 80 | public function canHandle(string $formatType): bool 81 | { 82 | return $formatType === 'json'; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grazulex/laravel-chronotrace", 3 | "description": "Record and replay Laravel requests deterministically and generate tests from production traces.", 4 | "keywords": [ 5 | "laravel", 6 | "chronotrace", 7 | "time-tracking", 8 | "pest", 9 | "testing", 10 | "activity-tracking", 11 | "time-management", 12 | "productivity", 13 | "clean-code", 14 | "php8.3", 15 | "laravel12" 16 | ], 17 | "type": "library", 18 | "homepage": "https://github.com/grazulex/laravel-chronotrace", 19 | "require": { 20 | "php": "^8.3", 21 | "illuminate/support": "^12.19", 22 | "nesbot/carbon": "^3.10", 23 | "illuminate/contracts": "^12.0" 24 | }, 25 | "require-dev": { 26 | "laravel/pint": "^1.22", 27 | "pestphp/pest": "^3.8", 28 | "pestphp/pest-plugin-laravel": "^3.2", 29 | "larastan/larastan": "^3.4", 30 | "rector/rector": "^2.0", 31 | "doctrine/dbal": "^4.2", 32 | "orchestra/testbench": "^10.0" 33 | }, 34 | "suggest": { 35 | "pestphp/pest": "Required to run and generate Chronotrace tests (version >=3.0)" 36 | }, 37 | "minimum-stability": "stable", 38 | "prefer-stable": true, 39 | "license": "MIT", 40 | "autoload": { 41 | "psr-4": { 42 | "Grazulex\\LaravelChronotrace\\": "src/" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Tests\\": "tests/" 48 | } 49 | }, 50 | "extra": { 51 | "laravel": { 52 | "providers": [ 53 | "Grazulex\\LaravelChronotrace\\LaravelChronotraceServiceProvider" 54 | ], 55 | "post-install-cmd": [ 56 | "@php artisan chronotrace:install" 57 | ], 58 | "post-update-cmd": [ 59 | "@php artisan chronotrace:install --force" 60 | ] 61 | } 62 | }, 63 | "authors": [ 64 | { 65 | "name": "Jean-Marc Strauven", 66 | "email": "jms@grazulex.be", 67 | "role": "Developer" 68 | } 69 | ], 70 | "support": { 71 | "issues": "https://github.com/Grazulex/laravel-chronotrace/issues", 72 | "source": "https://github.com/Grazulex/laravel-chronotrace", 73 | "forum": "https://github.com/Grazulex/laravel-chronotrace/discussions", 74 | "docs": "https://github.com/Grazulex/laravel-chronotrace#readme" 75 | }, 76 | "scripts": { 77 | "test": [ 78 | "./vendor/bin/pest --colors=always --coverage" 79 | ], 80 | "pint": [ 81 | "./vendor/bin/pint" 82 | ], 83 | "phpstan": [ 84 | "./vendor/bin/phpstan analyse --memory-limit=2G --configuration=phpstan.neon" 85 | ], 86 | "rector": [ 87 | "./vendor/bin/rector" 88 | ], 89 | "full": [ 90 | "composer run-script pint", 91 | "composer run-script phpstan", 92 | "composer run-script rector", 93 | "composer run-script test" 94 | ] 95 | }, 96 | "config": { 97 | "allow-plugins": { 98 | "pestphp/pest-plugin": true 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /src/Display/Events/JobEventDisplayer.php: -------------------------------------------------------------------------------- 1 | $options 18 | */ 19 | public function display(Command $command, TraceData $trace, array $options = []): void 20 | { 21 | $events = $this->getEventsByType($trace, 'jobs'); 22 | 23 | if ($events === []) { 24 | return; 25 | } 26 | 27 | $command->warn('⚙️ JOB EVENTS'); 28 | 29 | foreach ($events as $event) { 30 | if (! is_array($event)) { 31 | continue; 32 | } 33 | 34 | $type = $this->getStringValue($event, 'type', 'unknown'); 35 | $timestamp = $this->getTimestampFormatted($event); 36 | $jobName = $this->getStringValue($event, 'job_name', 'N/A'); 37 | $queue = $this->getStringValue($event, 'queue', 'default'); 38 | $connection = $this->getStringValue($event, 'connection', 'N/A'); 39 | 40 | match ($type) { 41 | 'job_processing' => $command->line(" 🔄 [{$timestamp}] Job STARTED: {$jobName} (queue: {$queue}, connection: {$connection})" . 42 | ($this->hasKey($event, 'attempts') ? ' - attempt #' . $this->getStringValue($event, 'attempts', '1') : '')), 43 | 'job_processed' => $command->line(" ✅ [{$timestamp}] Job COMPLETED: {$jobName} (queue: {$queue}, connection: {$connection})"), 44 | 'job_failed' => $command->line(" ❌ [{$timestamp}] Job FAILED: {$jobName} (queue: {$queue}, connection: {$connection})" . 45 | ($this->hasKey($event, 'exception') ? ' - ' . $this->getStringValue($event, 'exception', '') : '')), 46 | default => $command->line(" ❓ [{$timestamp}] Unknown job event: {$type}"), 47 | }; 48 | } 49 | 50 | $command->newLine(); 51 | } 52 | 53 | public function getEventType(): string 54 | { 55 | return 'jobs'; 56 | } 57 | 58 | public function canHandle(string $eventType): bool 59 | { 60 | return $eventType === 'jobs' || $eventType === 'queue'; 61 | } 62 | 63 | public function getSummary(array $events): array 64 | { 65 | $started = 0; 66 | $completed = 0; 67 | $failed = 0; 68 | 69 | foreach ($events as $event) { 70 | if (! is_array($event)) { 71 | continue; 72 | } 73 | 74 | $type = $this->getStringValue($event, 'type', 'unknown'); 75 | 76 | match ($type) { 77 | 'job_processing' => $started++, 78 | 'job_processed' => $completed++, 79 | 'job_failed' => $failed++, 80 | default => null, // Ignore les types inconnus 81 | }; 82 | } 83 | 84 | $total = $started + $completed + $failed; 85 | $successRate = $total > 0 ? round((($completed) / $total) * 100, 1) : 0; 86 | 87 | return [ 88 | 'started' => $started, 89 | 'completed' => $completed, 90 | 'failed' => $failed, 91 | 'success_rate' => $successRate, 92 | ]; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # Installation et Configuration 2 | 3 | ## Installation Automatique (Recommandée) 4 | 5 | ```bash 6 | composer require grazulex/laravel-chronotrace 7 | php artisan chronotrace:install 8 | ``` 9 | 10 | La commande `chronotrace:install` : 11 | - ✅ Publie automatiquement la configuration 12 | - ✅ Configure le middleware pour Laravel 11+ 13 | - ✅ Détecte votre version de Laravel 14 | - ✅ Fournit des instructions claires 15 | 16 | ## Configuration Manuelle (Laravel 11+) 17 | 18 | Si l'installation automatique échoue, ajoutez manuellement dans `bootstrap/app.php` : 19 | 20 | ```php 21 | withRouting(/* ... */) 30 | ->withMiddleware(function (Middleware $middleware) { 31 | $middleware->web(append: [ 32 | ChronoTraceMiddleware::class, 33 | ]); 34 | $middleware->api(append: [ 35 | ChronoTraceMiddleware::class, 36 | ]); 37 | }) 38 | ->withExceptions(function (Exceptions $exceptions) { 39 | // 40 | })->create(); 41 | ``` 42 | 43 | ## Test d'Installation 44 | 45 | Après installation, testez immédiatement : 46 | 47 | ```bash 48 | # 1. Tester le middleware 49 | php artisan chronotrace:test-middleware 50 | 51 | # 2. Diagnostiquer la configuration 52 | php artisan chronotrace:diagnose 53 | 54 | # 3. Configuration debug pour test 55 | # Dans .env 56 | CHRONOTRACE_ENABLED=true 57 | CHRONOTRACE_MODE=always 58 | CHRONOTRACE_DEBUG=true 59 | QUEUE_CONNECTION=sync 60 | 61 | # 4. Test avec serveur 62 | php artisan serve 63 | # Dans un autre terminal : 64 | curl http://localhost:8000/ 65 | # Vérifier logs : 66 | tail -f storage/logs/laravel.log | grep ChronoTrace 67 | 68 | # 5. Vérifier les traces générées 69 | php artisan chronotrace:list 70 | ``` 71 | \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class, 72 | ]); 73 | $middleware->api(append: [ 74 | \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class, 75 | ]); 76 | }) 77 | ->withExceptions(/* ... */); 78 | ``` 79 | 80 | ## Configuration Laravel <11 81 | 82 | Pour Laravel 10 et antérieur, le middleware est **automatiquement enregistré** par le service provider. 83 | Aucune configuration manuelle n'est nécessaire ! 84 | 85 | ## Vérification 86 | 87 | ```bash 88 | php artisan chronotrace:list 89 | # Doit afficher "Listing stored traces..." sans erreur 90 | ``` 91 | 92 | ## Variables d'Environnement 93 | 94 | ```env 95 | CHRONOTRACE_ENABLED=true 96 | CHRONOTRACE_MODE=always 97 | CHRONOTRACE_DEBUG=false 98 | ``` 99 | 100 | ## Pourquoi cette Configuration ? 101 | 102 | **Laravel 11+** a introduit un nouveau système de middleware dans `bootstrap/app.php` qui remplace l'ancien système du kernel. Les packages ne peuvent plus auto-enregistrer les middlewares via les service providers. 103 | 104 | Cette approche garantit : 105 | - ✅ **Transparence** : Vous voyez exactement quel middleware est actif 106 | - ✅ **Contrôle** : Vous pouvez facilement désactiver ou modifier l'ordre 107 | - ✅ **Performance** : Pas de "magie" cachée qui ralentit le boot 108 | - ✅ **Sécurité** : Aucun middleware n'est ajouté sans votre consentement explicite 109 | -------------------------------------------------------------------------------- /tests/Unit/Services/PIIScrubberTest.php: -------------------------------------------------------------------------------- 1 | ['password', 'token', 'secret']]); 7 | config(['chronotrace.scrub_patterns' => []]); 8 | }); 9 | 10 | it('can scrub sensitive data from arrays', function (): void { 11 | $scrubber = app(PIIScrubber::class); 12 | 13 | $data = [ 14 | 'username' => 'john', 15 | 'password' => 'secret123', 16 | 'email' => 'john@example.com', 17 | 'token' => 'abc123', 18 | 'nested' => [ 19 | 'secret' => 'hidden', 20 | 'public' => 'visible', 21 | ], 22 | ]; 23 | 24 | $scrubbed = $scrubber->scrubArray($data); 25 | 26 | expect($scrubbed['username'])->toBe('john'); 27 | expect($scrubbed['password'])->toBe('[SCRUBBED]'); 28 | expect($scrubbed['email'])->toBe('john@example.com'); 29 | expect($scrubbed['token'])->toBe('[SCRUBBED]'); 30 | expect($scrubbed['nested']['secret'])->toBe('[SCRUBBED]'); 31 | expect($scrubbed['nested']['public'])->toBe('visible'); 32 | }); 33 | 34 | it('can scrub sensitive data from strings with patterns', function (): void { 35 | config(['chronotrace.scrub_patterns' => [ 36 | '/password=\S+/', 37 | '/token=\S+/', 38 | ]]); 39 | 40 | $scrubber = app(PIIScrubber::class); 41 | 42 | $data = 'This contains a password=secret123 and token=abc123'; 43 | $scrubbed = $scrubber->scrubString($data); 44 | 45 | expect($scrubbed)->toContain('[SCRUBBED]'); 46 | expect($scrubbed)->not->toContain('secret123'); 47 | expect($scrubbed)->not->toContain('abc123'); 48 | }); 49 | it('handles empty data gracefully', function (): void { 50 | $scrubber = app(PIIScrubber::class); 51 | 52 | expect($scrubber->scrubArray([]))->toBe([]); 53 | expect($scrubber->scrubString(''))->toBe(''); 54 | }); 55 | it('preserves non-sensitive data', function (): void { 56 | $scrubber = app(PIIScrubber::class); 57 | 58 | $data = [ 59 | 'id' => 123, 60 | 'name' => 'John Doe', 61 | 'status' => 'active', 62 | 'created_at' => '2024-01-01', 63 | ]; 64 | 65 | $scrubbed = $scrubber->scrubArray($data); 66 | 67 | expect($scrubbed)->toBe($data); 68 | }); 69 | 70 | it('can instantiate service', function (): void { 71 | $scrubber = app(PIIScrubber::class); 72 | expect($scrubber)->toBeInstanceOf(PIIScrubber::class); 73 | }); 74 | 75 | it('scrubs case insensitive keys', function (): void { 76 | $scrubber = app(PIIScrubber::class); 77 | 78 | $data = [ 79 | 'PASSWORD' => 'secret', 80 | 'Token' => 'abc123', 81 | 'SECRET' => 'hidden', 82 | ]; 83 | 84 | $scrubbed = $scrubber->scrubArray($data); 85 | 86 | expect($scrubbed['PASSWORD'])->toBe('[SCRUBBED]'); 87 | expect($scrubbed['Token'])->toBe('[SCRUBBED]'); 88 | expect($scrubbed['SECRET'])->toBe('[SCRUBBED]'); 89 | }); 90 | 91 | it('handles nested arrays deeply', function (): void { 92 | $scrubber = app(PIIScrubber::class); 93 | 94 | $data = [ 95 | 'level1' => [ 96 | 'level2' => [ 97 | 'level3' => [ 98 | 'password' => 'deep_secret', 99 | ], 100 | ], 101 | ], 102 | ]; 103 | 104 | $scrubbed = $scrubber->scrubArray($data); 105 | 106 | expect($scrubbed['level1']['level2']['level3']['password'])->toBe('[SCRUBBED]'); 107 | }); 108 | -------------------------------------------------------------------------------- /tests/Feature/ReplayGenerateTestTest.php: -------------------------------------------------------------------------------- 1 | toISOString(), 15 | environment: 'testing', 16 | request: new TraceRequest( 17 | method: 'POST', 18 | url: '/api/users', 19 | headers: ['Content-Type' => 'application/json'], 20 | query: [], 21 | input: ['name' => 'John Doe', 'email' => 'john@example.com'], 22 | files: [], 23 | user: null, 24 | session: [], 25 | userAgent: 'Test Agent', 26 | ip: '127.0.0.1', 27 | timestamp: microtime(true) 28 | ), 29 | response: new TraceResponse( 30 | status: 201, 31 | headers: ['Content-Type' => 'application/json'], 32 | content: '{"id": 123, "name": "John Doe"}', 33 | duration: 0.15, 34 | memoryUsage: 1024, 35 | timestamp: microtime(true), 36 | exception: null, 37 | cookies: [] 38 | ), 39 | context: new TraceContext( 40 | laravel_version: '11.0', 41 | php_version: '8.3.0', 42 | config: [], 43 | env_vars: [] 44 | ) 45 | ); 46 | 47 | // Utiliser le générateur PestTestGenerator directement 48 | $generator = new PestTestGenerator; 49 | 50 | // Créer le dossier de test 51 | $testDir = 'tests/Generated'; 52 | if (! is_dir($testDir)) { 53 | mkdir($testDir, 0755, true); 54 | } 55 | 56 | // Générer le fichier de test 57 | $testFile = $generator->generate($trace, $testDir); 58 | 59 | // Vérifier que le fichier a été créé 60 | expect(file_exists($testFile))->toBeTrue(); 61 | 62 | // Lire le contenu du fichier généré 63 | $testContent = file_get_contents($testFile); 64 | 65 | // Vérifier le contenu généré 66 | expect($testContent)->toContain("it('trace replay for POST /api/users'"); 67 | expect($testContent)->toContain('$this->post(\'/api/users\''); 68 | expect($testContent)->toContain('$response->assertStatus(201)'); 69 | expect($testContent)->toContain('Generated Pest test from ChronoTrace'); 70 | expect($testContent)->toContain('Trace ID: test-trace-12345'); 71 | $content = file_get_contents($testFile); 72 | $this->assertStringStartsWith('toContain('trace replay for POST /api/users') 91 | ->toContain('$response->assertStatus(201)') 92 | ->toContain('uses(Tests\\TestCase::class, RefreshDatabase::class)') 93 | ->toContain('performs within acceptable time limits'); 94 | }); 95 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We release patches for security vulnerabilities in the following versions: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 1.x | :white_check_mark: | 10 | | < 1.0 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | We take the security of Laravel Statecraft seriously. If you believe you have found a security vulnerability, please report it to us as described below. 15 | 16 | ### How to Report 17 | 18 | **Please do not report security vulnerabilities through public GitHub issues.** 19 | 20 | Instead, please report them via email to: **grazulex@gmail.com** 21 | 22 | ### What to Include 23 | 24 | When reporting a vulnerability, please include the following information: 25 | 26 | - **Description**: A clear description of the vulnerability 27 | - **Impact**: What an attacker could achieve by exploiting this vulnerability 28 | - **Reproduction**: Step-by-step instructions to reproduce the issue 29 | - **Environment**: Laravel version, PHP version, and any other relevant details 30 | - **Proof of Concept**: If possible, include a minimal code example or proof of concept 31 | 32 | ### What to Expect 33 | 34 | - **Acknowledgment**: We will acknowledge receipt of your vulnerability report within 48 hours 35 | - **Initial Response**: We will provide an initial response within 7 days, including our assessment of the report 36 | - **Resolution**: We aim to resolve critical vulnerabilities within 30 days 37 | - **Disclosure**: We will coordinate with you on the responsible disclosure timeline 38 | 39 | ### Security Update Process 40 | 41 | 1. **Validation**: We validate and reproduce the reported vulnerability 42 | 2. **Fix Development**: We develop and test a fix 43 | 3. **Release**: We release a patch version with the security fix 44 | 4. **Disclosure**: We publish a security advisory with details about the vulnerability 45 | 46 | ## Security Best Practices 47 | 48 | When using Laravel Statecraft, please follow these security best practices: 49 | 50 | ### Input Validation 51 | 52 | - Always validate state transitions and guard/action inputs 53 | - Use Laravel's validation rules for any user-provided data 54 | - Sanitize any metadata passed to state transitions 55 | 56 | ### Authorization 57 | 58 | - Implement proper authorization checks in your guards 59 | - Use Laravel's authorization features (policies, gates) 60 | - Never trust client-side validation alone 61 | 62 | ### Configuration 63 | 64 | - Review your `config/statecraft.php` configuration 65 | - Ensure generated code paths are secure 66 | - Use environment variables for sensitive configuration 67 | 68 | ### Database Security 69 | 70 | - Use Laravel's Eloquent ORM to prevent SQL injection 71 | - Validate all database queries in custom guards/actions 72 | - Follow Laravel's database security guidelines 73 | 74 | ## Scope 75 | 76 | This security policy applies to: 77 | 78 | - The main Laravel Statecraft package 79 | - Generated code from the package's commands 80 | - Configuration files and examples 81 | 82 | This policy does not cover: 83 | 84 | - Third-party packages used by Laravel Statecraft 85 | - User-implemented guards and actions 86 | - Custom state machine definitions (YAML files) 87 | 88 | ## Updates to This Policy 89 | 90 | This security policy may be updated from time to time. We will announce significant changes through our release notes. 91 | 92 | ## Contact 93 | 94 | For questions about this security policy, please contact us at: **jms@grazulex.be** 95 | 96 | --- 97 | 98 | **Thank you for helping keep Laravel Statecraft secure!** 99 | -------------------------------------------------------------------------------- /config/chronotrace.php: -------------------------------------------------------------------------------- 1 | env('CHRONOTRACE_ENABLED', true), 6 | 'mode' => env('CHRONOTRACE_MODE', 'record_on_error'), // always | sample | record_on_error | targeted 7 | 'sample_rate' => env('CHRONOTRACE_SAMPLE_RATE', 0.001), // 0.1% des requêtes réussies 8 | 9 | // Stockage 10 | 'storage' => env('CHRONOTRACE_STORAGE', 'local'), // local | s3 | minio 11 | 'path' => env('CHRONOTRACE_PATH', storage_path('chronotrace')), 12 | 13 | // Configuration S3/Minio 14 | 's3' => [ 15 | 'bucket' => env('CHRONOTRACE_S3_BUCKET', 'chronotrace'), 16 | 'region' => env('CHRONOTRACE_S3_REGION', 'us-east-1'), 17 | 'endpoint' => env('CHRONOTRACE_S3_ENDPOINT'), // Pour Minio 18 | 'path_prefix' => env('CHRONOTRACE_S3_PREFIX', 'traces'), 19 | ], 20 | 21 | // Rétention et purge 22 | 'retention_days' => env('CHRONOTRACE_RETENTION_DAYS', 15), 23 | 'auto_purge' => env('CHRONOTRACE_AUTO_PURGE', true), 24 | 25 | // Compression et optimisation 26 | 'compression' => [ 27 | 'enabled' => true, 28 | 'level' => 6, // Niveau de compression gzip (1-9) 29 | 'max_payload_size' => 1024 * 1024, // 1MB - au-delà, on stocke en blob séparé 30 | ], 31 | 32 | // Configuration de capture des événements 33 | 'capture' => [ 34 | 'database' => env('CHRONOTRACE_CAPTURE_DATABASE', true), 35 | 'cache' => env('CHRONOTRACE_CAPTURE_CACHE', true), 36 | 'http' => env('CHRONOTRACE_CAPTURE_HTTP', true), 37 | 'jobs' => env('CHRONOTRACE_CAPTURE_JOBS', true), 38 | 'events' => env('CHRONOTRACE_CAPTURE_EVENTS', false), // Désactivé par défaut (verbose) 39 | ], 40 | 41 | // Sécurité et PII 42 | /* 43 | |-------------------------------------------------------------------------- 44 | | PII Scrubbing Configuration 45 | |-------------------------------------------------------------------------- 46 | */ 47 | 'scrub' => [ 48 | 'password', 49 | 'token', 50 | 'secret', 51 | 'key', 52 | 'authorization', 53 | 'cookie', 54 | 'session', 55 | 'credit_card', 56 | 'ssn', 57 | 'email', 58 | 'phone', 59 | ], 60 | 61 | // Routes et jobs ciblés (pour mode 'targeted') 62 | 'targets' => [ 63 | 'routes' => [ 64 | 'checkout/*', 65 | 'payment/*', 66 | 'orders/*', 67 | ], 68 | 'jobs' => [ 69 | 'ProcessPayment', 70 | 'SendOrderConfirmation', 71 | ], 72 | ], 73 | 74 | // Capture - ce qu'on enregistre 75 | 'capture' => [ 76 | 'request' => true, 77 | 'response' => true, 78 | 'database' => true, 79 | 'cache' => true, 80 | 'http' => true, // Requêtes HTTP externes 81 | 'mail' => true, 82 | 'notifications' => true, 83 | 'events' => true, 84 | 'jobs' => true, 85 | 'filesystem' => false, // Peut être lourd 86 | ], 87 | 88 | // Performance 89 | 'async_storage' => env('CHRONOTRACE_ASYNC_STORAGE', true), // Stockage asynchrone via queue 90 | 'queue_connection' => env('CHRONOTRACE_QUEUE_CONNECTION', null), // null = auto-detect 91 | 'queue_name' => env('CHRONOTRACE_QUEUE', 'chronotrace'), 92 | 'queue_fallback' => env('CHRONOTRACE_QUEUE_FALLBACK', true), // Fallback vers sync si queue échoue 93 | 94 | // Debug et développement 95 | 'debug' => env('CHRONOTRACE_DEBUG', env('APP_DEBUG', false)), // Auto-enable en mode debug 96 | 'local_replay_db' => env('CHRONOTRACE_REPLAY_DB', 'sqlite'), // sqlite | memory 97 | ]; 98 | -------------------------------------------------------------------------------- /src/Display/Events/HttpEventDisplayer.php: -------------------------------------------------------------------------------- 1 | $options 18 | */ 19 | public function display(Command $command, TraceData $trace, array $options = []): void 20 | { 21 | $events = $this->getEventsByType($trace, 'http'); 22 | 23 | if ($events === []) { 24 | return; 25 | } 26 | 27 | $command->warn('🌐 HTTP EVENTS'); 28 | 29 | foreach ($events as $event) { 30 | if (! is_array($event)) { 31 | continue; 32 | } 33 | 34 | $type = $this->getStringValue($event, 'type', 'unknown'); 35 | $timestamp = $this->getTimestampFormatted($event); 36 | $url = $this->getStringValue($event, 'url', 'N/A'); 37 | $method = $this->getStringValue($event, 'method', 'N/A'); 38 | 39 | match ($type) { 40 | 'request_sending' => $command->line(" 📤 [{$timestamp}] HTTP Request: {$method} {$url}" . 41 | ($this->hasKey($event, 'body_size') ? ' (body: ' . $this->getStringValue($event, 'body_size', '0') . ' bytes)' : '')), 42 | 'response_received' => $command->line(" 📥 [{$timestamp}] HTTP Response: {$method} {$url} → " . 43 | $this->getStringValue($event, 'status', 'N/A') . 44 | ($this->hasKey($event, 'response_size') ? ' (' . $this->getStringValue($event, 'response_size', '0') . ' bytes)' : '')), 45 | 'connection_failed' => $command->line(" ❌ [{$timestamp}] HTTP Connection Failed: {$method} {$url}"), 46 | default => $command->line(" ❓ [{$timestamp}] Unknown HTTP event: {$type}"), 47 | }; 48 | } 49 | 50 | $command->newLine(); 51 | } 52 | 53 | public function getEventType(): string 54 | { 55 | return 'http'; 56 | } 57 | 58 | public function canHandle(string $eventType): bool 59 | { 60 | return $eventType === 'http'; 61 | } 62 | 63 | public function getSummary(array $events): array 64 | { 65 | $requests = 0; 66 | $responses = 0; 67 | $failures = 0; 68 | $totalBytes = 0; 69 | 70 | foreach ($events as $event) { 71 | if (! is_array($event)) { 72 | continue; 73 | } 74 | 75 | $type = $this->getStringValue($event, 'type', 'unknown'); 76 | 77 | match ($type) { 78 | 'request_sending' => $requests++, 79 | 'response_received' => $responses++, 80 | 'connection_failed' => $failures++, 81 | default => null, // Ignore les types inconnus 82 | }; 83 | 84 | if ($this->hasKey($event, 'response_size')) { 85 | $size = $this->getStringValue($event, 'response_size', '0'); 86 | $totalBytes += is_numeric($size) ? (int) $size : 0; 87 | } 88 | } 89 | 90 | return [ 91 | 'requests' => $requests, 92 | 'responses' => $responses, 93 | 'failures' => $failures, 94 | 'success_rate' => $requests > 0 ? round(($responses / $requests) * 100, 1) : 0, 95 | 'total_bytes' => $totalBytes, 96 | ]; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Display/AbstractEventDisplayer.php: -------------------------------------------------------------------------------- 1 | $array 47 | */ 48 | protected function getStringValue(array $array, string $key, string $default = ''): string 49 | { 50 | if (! isset($array[$key])) { 51 | return $default; 52 | } 53 | 54 | $value = $array[$key]; 55 | if (is_string($value)) { 56 | return $value; 57 | } 58 | 59 | if (is_numeric($value)) { 60 | return $this->formatValue($value); 61 | } 62 | 63 | return $default; 64 | } 65 | 66 | /** 67 | * Vérifie si une clé existe dans l'array 68 | * 69 | * @param array $array 70 | */ 71 | protected function hasKey(array $array, string $key): bool 72 | { 73 | return isset($array[$key]); 74 | } 75 | 76 | /** 77 | * Formate un timestamp de manière sécurisée 78 | * 79 | * @param array $event 80 | */ 81 | protected function getTimestampFormatted(array $event): string 82 | { 83 | if (! isset($event['timestamp'])) { 84 | return 'N/A'; 85 | } 86 | 87 | $timestamp = $event['timestamp']; 88 | if (is_numeric($timestamp)) { 89 | return date('H:i:s.v', (int) $timestamp); 90 | } 91 | 92 | return 'N/A'; 93 | } 94 | 95 | /** 96 | * Vérifie si des options sont définies pour l'affichage détaillé 97 | * 98 | * @param array $options 99 | */ 100 | protected function shouldShowDetailed(array $options): bool 101 | { 102 | return (bool) ($options['detailed'] ?? false); 103 | } 104 | 105 | /** 106 | * Vérifie si des bindings doivent être affichés 107 | * 108 | * @param array $options 109 | */ 110 | protected function shouldShowBindings(array $options): bool 111 | { 112 | return (bool) ($options['bindings'] ?? false); 113 | } 114 | 115 | /** 116 | * Récupère les événements d'un type spécifique 117 | * 118 | * @return array 119 | */ 120 | protected function getEventsByType(TraceData $trace, string $type): array 121 | { 122 | return match ($type) { 123 | 'database' => $trace->database, 124 | 'cache' => $trace->cache, 125 | 'http' => $trace->http, 126 | 'jobs' => $trace->jobs, 127 | default => [], 128 | }; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/Feature/Commands/ReplayCommandTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('retrieve') 68 | ->with('abc123') 69 | ->once() 70 | ->andReturn($traceData); 71 | 72 | $this->instance(TraceStorage::class, $storage); 73 | 74 | $this->artisan(ReplayCommand::class, ['trace-id' => 'abc123']) 75 | ->expectsOutput('Replaying trace abc123...') 76 | ->expectsOutput('=== TRACE INFORMATION ===') 77 | ->expectsOutput('🆔 Trace ID: abc123') 78 | ->expectsOutput('🌍 Environment: testing') 79 | ->expectsOutput('🔗 Request URL: https://example.com/test') 80 | ->expectsOutput('📊 Response Status: 200') 81 | ->expectsOutput('=== CAPTURED EVENTS ===') 82 | ->assertExitCode(0); 83 | }); 84 | 85 | it('handles missing trace gracefully', function (): void { 86 | $storage = Mockery::mock(TraceStorage::class); 87 | $storage->shouldReceive('retrieve') 88 | ->with('missing') 89 | ->once() 90 | ->andReturn(null); 91 | 92 | $this->instance(TraceStorage::class, $storage); 93 | 94 | $this->artisan(ReplayCommand::class, ['trace-id' => 'missing']) 95 | ->expectsOutput('Trace missing not found.') 96 | ->assertExitCode(1); 97 | }); 98 | 99 | it('handles storage errors gracefully', function (): void { 100 | $storage = Mockery::mock(TraceStorage::class); 101 | $storage->shouldReceive('retrieve') 102 | ->once() 103 | ->andThrow(new Exception('Storage error')); 104 | 105 | $this->instance(TraceStorage::class, $storage); 106 | 107 | $this->artisan(ReplayCommand::class, ['trace-id' => 'abc123']) 108 | ->expectsOutput('Failed to replay trace: Storage error') 109 | ->assertExitCode(1); 110 | }); 111 | -------------------------------------------------------------------------------- /src/Models/TraceData.php: -------------------------------------------------------------------------------- 1 | $database 14 | * @param array $cache 15 | * @param array $http 16 | * @param array $mail 17 | * @param array $notifications 18 | * @param array $events 19 | * @param array $jobs 20 | * @param array $filesystem 21 | * @param array $metadata 22 | */ 23 | public function __construct( 24 | public readonly string $traceId, 25 | public readonly string $timestamp, 26 | public readonly string $environment, 27 | public readonly TraceRequest $request, 28 | public readonly TraceResponse $response, 29 | public readonly TraceContext $context, 30 | public readonly array $database = [], 31 | public readonly array $cache = [], 32 | public readonly array $http = [], 33 | public readonly array $mail = [], 34 | public readonly array $notifications = [], 35 | public readonly array $events = [], 36 | public readonly array $jobs = [], 37 | public readonly array $filesystem = [], 38 | public readonly array $metadata = [], 39 | ) {} 40 | 41 | public function toArray(): array 42 | { 43 | return [ 44 | 'trace_id' => $this->traceId, 45 | 'timestamp' => $this->timestamp, 46 | 'environment' => $this->environment, 47 | 'request' => $this->request->toArray(), 48 | 'response' => $this->response->toArray(), 49 | 'context' => $this->context->toArray(), 50 | 'database' => $this->database, 51 | 'cache' => $this->cache, 52 | 'http' => $this->http, 53 | 'mail' => $this->mail, 54 | 'notifications' => $this->notifications, 55 | 'events' => $this->events, 56 | 'jobs' => $this->jobs, 57 | 'filesystem' => $this->filesystem, 58 | 'metadata' => $this->metadata, 59 | ]; 60 | } 61 | 62 | /** 63 | * @param array $data 64 | */ 65 | public static function fromArray(array $data): self 66 | { 67 | $requestData = $data['request'] ?? []; 68 | $responseData = $data['response'] ?? []; 69 | $contextData = $data['context'] ?? []; 70 | 71 | return new self( 72 | traceId: $data['trace_id'], 73 | timestamp: $data['timestamp'], 74 | environment: $data['environment'], 75 | request: TraceRequest::fromArray(is_array($requestData) ? $requestData : []), 76 | response: TraceResponse::fromArray(is_array($responseData) ? $responseData : []), 77 | context: TraceContext::fromArray(is_array($contextData) ? $contextData : []), 78 | database: is_array($data['database'] ?? []) ? $data['database'] ?? [] : [], 79 | cache: is_array($data['cache'] ?? []) ? $data['cache'] ?? [] : [], 80 | http: is_array($data['http'] ?? []) ? $data['http'] ?? [] : [], 81 | mail: is_array($data['mail'] ?? []) ? $data['mail'] ?? [] : [], 82 | notifications: is_array($data['notifications'] ?? []) ? $data['notifications'] ?? [] : [], 83 | events: is_array($data['events'] ?? []) ? $data['events'] ?? [] : [], 84 | jobs: is_array($data['jobs'] ?? []) ? $data['jobs'] ?? [] : [], 85 | filesystem: is_array($data['filesystem'] ?? []) ? $data['filesystem'] ?? [] : [], 86 | metadata: is_array($data['metadata'] ?? []) ? $data['metadata'] ?? [] : [], 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Feature/Commands/ListCommandTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('list') 9 | ->once() 10 | ->andReturn([ 11 | [ 12 | 'trace_id' => 'abc123def456', 13 | 'size' => 1024, 14 | 'created_at' => time(), 15 | ], 16 | ]); 17 | 18 | $this->instance(TraceStorage::class, $storage); 19 | 20 | $this->artisan(ListCommand::class) 21 | ->expectsOutput('Listing stored traces...') 22 | ->expectsTable( 23 | ['Trace ID', 'Size', 'Created At'], 24 | [['abc123de...', '1,024 bytes', date('Y-m-d H:i:s')]] 25 | ) 26 | ->assertExitCode(0); 27 | }); 28 | 29 | it('shows warning when no traces found', function (): void { 30 | $storage = Mockery::mock(TraceStorage::class); 31 | $storage->shouldReceive('list') 32 | ->once() 33 | ->andReturn([]); 34 | 35 | $this->instance(TraceStorage::class, $storage); 36 | 37 | $this->artisan(ListCommand::class) 38 | ->expectsOutput('Listing stored traces...') 39 | ->expectsOutput('No traces found.') 40 | ->assertExitCode(0); 41 | }); 42 | 43 | it('handles storage errors gracefully', function (): void { 44 | $mockStorage = Mockery::mock(TraceStorage::class); 45 | $mockStorage->shouldReceive('list') 46 | ->andThrow(new Exception('Storage error')); 47 | 48 | $this->app->instance(TraceStorage::class, $mockStorage); 49 | 50 | $this->artisan('chronotrace:list') 51 | ->expectsOutput('Listing stored traces...') 52 | ->expectsOutput('Failed to list traces: Storage error') 53 | ->assertExitCode(1); 54 | }); 55 | 56 | it('can show full trace IDs when requested', function (): void { 57 | $mockTraces = [ 58 | [ 59 | 'trace_id' => 'abc12345-def6-7890-abcd-ef1234567890', 60 | 'size' => 1024, 61 | 'created_at' => time(), 62 | ], 63 | [ 64 | 'trace_id' => 'xyz98765-fed4-3210-zyxw-987654321abc', 65 | 'size' => 2048, 66 | 'created_at' => time() - 3600, 67 | ], 68 | ]; 69 | 70 | $mockStorage = Mockery::mock(TraceStorage::class); 71 | $mockStorage->shouldReceive('list') 72 | ->andReturn($mockTraces); 73 | 74 | $this->app->instance(TraceStorage::class, $mockStorage); 75 | 76 | // Test avec --full-id 77 | $this->artisan('chronotrace:list', ['--full-id' => true]) 78 | ->expectsOutput('Listing stored traces...') 79 | ->expectsOutputToContain('abc12345-def6-7890-abcd-ef1234567890') // ID complet 80 | ->expectsOutputToContain('xyz98765-fed4-3210-zyxw-987654321abc') // ID complet 81 | ->expectsOutput('Showing 20 of 2 traces.') 82 | ->assertExitCode(0); 83 | }); 84 | 85 | it('shows truncated trace IDs by default', function (): void { 86 | $mockTraces = [ 87 | [ 88 | 'trace_id' => 'abc12345-def6-7890-abcd-ef1234567890', 89 | 'size' => 1024, 90 | 'created_at' => time(), 91 | ], 92 | ]; 93 | 94 | $mockStorage = Mockery::mock(TraceStorage::class); 95 | $mockStorage->shouldReceive('list') 96 | ->andReturn($mockTraces); 97 | 98 | $this->app->instance(TraceStorage::class, $mockStorage); 99 | 100 | // Test sans --full-id (comportement par défaut) 101 | $this->artisan('chronotrace:list') 102 | ->expectsOutput('Listing stored traces...') 103 | ->expectsOutputToContain('abc12345...') // ID tronqué 104 | ->doesntExpectOutputToContain('abc12345-def6-7890-abcd-ef1234567890') // Pas l'ID complet 105 | ->expectsOutput('Showing 20 of 1 traces.') 106 | ->assertExitCode(0); 107 | }); 108 | -------------------------------------------------------------------------------- /tests/Integration/ListCommandFixTest.php: -------------------------------------------------------------------------------- 1 | list(); 43 | 44 | // Vérifier qu'on trouve bien les 3 traces 45 | $this->assertCount(3, $traces); 46 | 47 | // Vérifier la structure des données retournées 48 | foreach ($traces as $trace) { 49 | $this->assertArrayHasKey('trace_id', $trace); 50 | $this->assertArrayHasKey('path', $trace); 51 | $this->assertArrayHasKey('size', $trace); 52 | $this->assertArrayHasKey('created_at', $trace); 53 | } 54 | 55 | // Vérifier les IDs des traces 56 | $traceIds = array_column($traces, 'trace_id'); 57 | $this->assertContains('ct_test1_123456', $traceIds); 58 | $this->assertContains('ct_test2_123457', $traceIds); 59 | $this->assertContains('ct_test3_123458', $traceIds); 60 | } 61 | 62 | public function test_list_command_shows_no_traces_when_empty(): void 63 | { 64 | $traceStorage = new TraceStorage; 65 | $traces = $traceStorage->list(); 66 | 67 | $this->assertCount(0, $traces); 68 | } 69 | 70 | public function test_list_command_ignores_non_zip_files(): void 71 | { 72 | $today = date('Y-m-d'); 73 | 74 | // Créer des fichiers de différents types 75 | Storage::put("traces/{$today}/ct_test1_123456.zip", 'trace data 1'); 76 | Storage::put("traces/{$today}/ct_test2_123457.json", 'not a trace'); 77 | Storage::put("traces/{$today}/readme.txt", 'readme file'); 78 | 79 | $traceStorage = new TraceStorage; 80 | $traces = $traceStorage->list(); 81 | 82 | // Ne doit trouver que le fichier .zip 83 | $this->assertCount(1, $traces); 84 | $this->assertSame('ct_test1_123456', $traces[0]['trace_id']); 85 | } 86 | 87 | public function test_list_command_sorts_by_creation_date_desc(): void 88 | { 89 | $today = date('Y-m-d'); 90 | $yesterday = date('Y-m-d', strtotime('-1 day')); 91 | 92 | // Créer des fichiers dans des dossiers de dates différentes 93 | Storage::put("traces/{$yesterday}/ct_old_123456.zip", 'old trace'); 94 | Storage::put("traces/{$today}/ct_new_123457.zip", 'new trace'); 95 | 96 | $traceStorage = new TraceStorage; 97 | $traces = $traceStorage->list(); 98 | 99 | $this->assertCount(2, $traces); 100 | 101 | // Vérifier qu'on a bien les deux traces 102 | $traceIds = array_column($traces, 'trace_id'); 103 | $this->assertContains('ct_old_123456', $traceIds); 104 | $this->assertContains('ct_new_123457', $traceIds); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Listeners/CacheEventListener.php: -------------------------------------------------------------------------------- 1 | traceRecorder->addCapturedData('cache', [ 32 | 'type' => 'hit', 33 | 'key' => $this->scrubCacheKey($event->key), 34 | 'value_size' => $this->getValueSize($event->value), 35 | 'store' => $event->storeName ?? 'default', 36 | 'timestamp' => microtime(true), 37 | ]); 38 | } 39 | 40 | /** 41 | * Capture les miss de cache 42 | */ 43 | public function handleCacheMissed(CacheMissed $event): void 44 | { 45 | if (! config('chronotrace.capture.cache', true)) { 46 | return; 47 | } 48 | 49 | $this->traceRecorder->addCapturedData('cache', [ 50 | 'type' => 'miss', 51 | 'key' => $this->scrubCacheKey($event->key), 52 | 'store' => $event->storeName ?? 'default', 53 | 'timestamp' => microtime(true), 54 | ]); 55 | } 56 | 57 | /** 58 | * Capture les écritures de cache 59 | */ 60 | public function handleKeyWritten(KeyWritten $event): void 61 | { 62 | if (! config('chronotrace.capture.cache', true)) { 63 | return; 64 | } 65 | 66 | $this->traceRecorder->addCapturedData('cache', [ 67 | 'type' => 'write', 68 | 'key' => $this->scrubCacheKey($event->key), 69 | 'value_size' => $this->getValueSize($event->value), 70 | 'store' => $event->storeName ?? 'default', 71 | 'timestamp' => microtime(true), 72 | ]); 73 | } 74 | 75 | /** 76 | * Capture les suppressions de cache 77 | */ 78 | public function handleKeyForgotten(KeyForgotten $event): void 79 | { 80 | if (! config('chronotrace.capture.cache', true)) { 81 | return; 82 | } 83 | 84 | $this->traceRecorder->addCapturedData('cache', [ 85 | 'type' => 'forget', 86 | 'key' => $this->scrubCacheKey($event->key), 87 | 'store' => $event->storeName ?? 'default', 88 | 'timestamp' => microtime(true), 89 | ]); 90 | } 91 | 92 | /** 93 | * Nettoie les clés de cache sensibles 94 | */ 95 | private function scrubCacheKey(string $key): string 96 | { 97 | // Masquer les clés contenant des données sensibles 98 | $sensitivePatterns = [ 99 | '/user_\d+_token/', 100 | '/session_[a-f0-9]{40}/', 101 | '/auth_[a-f0-9]+/', 102 | ]; 103 | 104 | foreach ($sensitivePatterns as $pattern) { 105 | if (preg_match($pattern, $key)) { 106 | return '[SCRUBBED_CACHE_KEY]'; 107 | } 108 | } 109 | 110 | return $key; 111 | } 112 | 113 | /** 114 | * Calcule la taille approximative d'une valeur 115 | */ 116 | private function getValueSize(mixed $value): int 117 | { 118 | if (is_string($value)) { 119 | return strlen($value); 120 | } 121 | 122 | if (is_array($value) || is_object($value)) { 123 | $serialized = serialize($value); 124 | 125 | return strlen($serialized); 126 | } 127 | 128 | if (is_scalar($value)) { 129 | return strlen((string) $value); 130 | } 131 | 132 | return 0; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Display/Events/DatabaseEventDisplayer.php: -------------------------------------------------------------------------------- 1 | $options 18 | */ 19 | public function display(Command $command, TraceData $trace, array $options = []): void 20 | { 21 | $events = $this->getEventsByType($trace, 'database'); 22 | 23 | if ($events === []) { 24 | return; 25 | } 26 | 27 | $command->warn('📊 DATABASE EVENTS'); 28 | 29 | foreach ($events as $event) { 30 | if (! is_array($event)) { 31 | continue; 32 | } 33 | 34 | $type = $this->getStringValue($event, 'type', 'unknown'); 35 | $timestamp = $this->getTimestampFormatted($event); 36 | 37 | match ($type) { 38 | 'query' => $this->displayDatabaseQuery($command, $event, $timestamp, $options), 39 | 'transaction_begin' => $command->line(" 🔄 [{$timestamp}] Transaction BEGIN on " . $this->getStringValue($event, 'connection', 'N/A')), 40 | 'transaction_commit' => $command->line(" ✅ [{$timestamp}] Transaction COMMIT on " . $this->getStringValue($event, 'connection', 'N/A')), 41 | 'transaction_rollback' => $command->line(" ❌ [{$timestamp}] Transaction ROLLBACK on " . $this->getStringValue($event, 'connection', 'N/A')), 42 | default => $command->line(" ❓ [{$timestamp}] Unknown database event: {$type}"), 43 | }; 44 | } 45 | 46 | $command->newLine(); 47 | } 48 | 49 | public function getEventType(): string 50 | { 51 | return 'database'; 52 | } 53 | 54 | public function canHandle(string $eventType): bool 55 | { 56 | return $eventType === 'database' || $eventType === 'db'; 57 | } 58 | 59 | public function getSummary(array $events): array 60 | { 61 | $queryCount = 0; 62 | $transactionCount = 0; 63 | $totalTime = 0; 64 | 65 | foreach ($events as $event) { 66 | if (! is_array($event)) { 67 | continue; 68 | } 69 | 70 | $type = $this->getStringValue($event, 'type', 'unknown'); 71 | 72 | if ($type === 'query') { 73 | $queryCount++; 74 | $time = $this->getStringValue($event, 'time', '0'); 75 | $totalTime += is_numeric($time) ? (float) $time : 0; 76 | } elseif (str_contains($type, 'transaction')) { 77 | $transactionCount++; 78 | } 79 | } 80 | 81 | return [ 82 | 'queries' => $queryCount, 83 | 'transactions' => $transactionCount, 84 | 'total_time' => $totalTime, 85 | 'avg_time' => $queryCount > 0 ? round($totalTime / $queryCount, 3) : 0, 86 | ]; 87 | } 88 | 89 | /** 90 | * Affiche les détails d'une requête SQL 91 | * 92 | * @param array $event 93 | * @param array $options 94 | */ 95 | private function displayDatabaseQuery(Command $command, array $event, string $timestamp, array $options): void 96 | { 97 | $sql = $this->getStringValue($event, 'sql', 'N/A'); 98 | $time = $this->getStringValue($event, 'time', '0'); 99 | $connection = $this->getStringValue($event, 'connection', 'N/A'); 100 | 101 | $command->line(" 🔍 [{$timestamp}] Query: {$sql} ({$time}ms on {$connection})"); 102 | 103 | // Afficher les bindings si demandé et disponibles 104 | if (($this->shouldShowDetailed($options) || $this->shouldShowBindings($options)) && 105 | isset($event['bindings']) && is_array($event['bindings']) && $event['bindings'] !== []) { 106 | $command->line(' 📎 Bindings: ' . json_encode($event['bindings'])); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/Unit/Services/TraceRecorderTest.php: -------------------------------------------------------------------------------- 1 | true]); 9 | config(['chronotrace.mode' => 'always']); 10 | config(['chronotrace.storage_mode' => 'sync']); 11 | }); 12 | 13 | it('can start capture', function (): void { 14 | $recorder = app(TraceRecorder::class); 15 | $request = Request::create('/test', 'GET'); 16 | 17 | $traceId = $recorder->startCapture($request); 18 | 19 | expect($traceId)->toStartWith('ct_'); 20 | expect(strlen($traceId))->toBeGreaterThan(10); 21 | }); 22 | 23 | it('can finish capture successfully', function (): void { 24 | $recorder = app(TraceRecorder::class); 25 | $request = Request::create('/test', 'GET'); 26 | 27 | $traceId = $recorder->startCapture($request); 28 | $response = new Response('test content', 200); 29 | 30 | $recorder->finishCapture($traceId, $response, 0.1, 1024); 31 | 32 | expect(true)->toBeTrue(); // Si on arrive ici, ça a marché 33 | }); 34 | 35 | it('handles finish capture with exception', function (): void { 36 | $recorder = app(TraceRecorder::class); 37 | $request = Request::create('/test', 'GET'); 38 | 39 | $traceId = $recorder->startCapture($request); 40 | $exception = new Exception('Test exception'); 41 | 42 | $recorder->finishCaptureWithException($traceId, $exception, 0.1, 1024); 43 | 44 | expect(true)->toBeTrue(); 45 | }); 46 | 47 | it('captures request data', function (): void { 48 | $recorder = app(TraceRecorder::class); 49 | $request = Request::create('/test', 'POST', ['key' => 'value']); 50 | $request->headers->set('Authorization', 'Bearer token'); 51 | 52 | $traceId = $recorder->startCapture($request); 53 | 54 | expect($traceId)->toStartWith('ct_'); 55 | }); 56 | 57 | it('handles storage mode async', function (): void { 58 | config(['chronotrace.storage_mode' => 'async']); 59 | 60 | $recorder = app(TraceRecorder::class); 61 | $request = Request::create('/test', 'GET'); 62 | 63 | $traceId = $recorder->startCapture($request); 64 | $response = new Response('test content', 200); 65 | 66 | $recorder->finishCapture($traceId, $response, 0.1, 1024); 67 | 68 | expect(true)->toBeTrue(); 69 | }); 70 | 71 | it('respects record on error mode', function (): void { 72 | config(['chronotrace.mode' => 'error_only']); 73 | 74 | $recorder = app(TraceRecorder::class); 75 | $request = Request::create('/test', 'GET'); 76 | 77 | $traceId = $recorder->startCapture($request); 78 | $response = new Response('error', 500); 79 | 80 | $recorder->finishCapture($traceId, $response, 0.1, 1024); 81 | 82 | expect(true)->toBeTrue(); 83 | }); 84 | 85 | it('handles sample mode', function (): void { 86 | config(['chronotrace.mode' => 'sample']); 87 | config(['chronotrace.sample_rate' => 1.0]); // 100% pour garantir la capture 88 | 89 | $recorder = app(TraceRecorder::class); 90 | $request = Request::create('/test', 'GET'); 91 | 92 | $traceId = $recorder->startCapture($request); 93 | 94 | expect($traceId)->toStartWith('ct_'); 95 | }); 96 | 97 | it('handles targeted routes mode', function (): void { 98 | config(['chronotrace.mode' => 'targeted_routes']); 99 | config(['chronotrace.targeted_routes' => ['/api/*', '/admin/*']]); 100 | 101 | $recorder = app(TraceRecorder::class); 102 | $request = Request::create('/api/users', 'GET'); 103 | 104 | $traceId = $recorder->startCapture($request); 105 | 106 | expect($traceId)->toStartWith('ct_'); 107 | }); 108 | 109 | it('can instantiate service', function (): void { 110 | $recorder = app(TraceRecorder::class); 111 | expect($recorder)->toBeInstanceOf(TraceRecorder::class); 112 | }); 113 | 114 | it('generates unique trace IDs', function (): void { 115 | $recorder = app(TraceRecorder::class); 116 | $request = Request::create('/test', 'GET'); 117 | 118 | $traceId1 = $recorder->startCapture($request); 119 | $traceId2 = $recorder->startCapture($request); 120 | 121 | expect($traceId1)->not->toBe($traceId2); 122 | expect($traceId1)->toStartWith('ct_'); 123 | expect($traceId2)->toStartWith('ct_'); 124 | }); 125 | -------------------------------------------------------------------------------- /src/Listeners/HttpEventListener.php: -------------------------------------------------------------------------------- 1 | traceRecorder->addCapturedData('http', [ 31 | 'type' => 'request_sending', 32 | 'method' => $event->request->method(), 33 | 'url' => $this->scrubUrl($event->request->url()), 34 | 'headers' => $this->scrubHeaders($event->request->headers()), 35 | 'body_size' => $this->getBodySize($event->request->body()), 36 | 'timestamp' => microtime(true), 37 | ]); 38 | } 39 | 40 | /** 41 | * Capture la réception de réponses HTTP 42 | */ 43 | public function handleResponseReceived(ResponseReceived $event): void 44 | { 45 | if (! config('chronotrace.capture.http', true)) { 46 | return; 47 | } 48 | 49 | $this->traceRecorder->addCapturedData('http', [ 50 | 'type' => 'response_received', 51 | 'method' => $event->request->method(), 52 | 'url' => $this->scrubUrl($event->request->url()), 53 | 'status' => $event->response->status(), 54 | 'response_size' => strlen($event->response->body()), 55 | 'headers' => $this->scrubHeaders($event->response->headers()), 56 | 'timestamp' => microtime(true), 57 | ]); 58 | } 59 | 60 | /** 61 | * Capture les échecs de connexion 62 | */ 63 | public function handleConnectionFailed(ConnectionFailed $event): void 64 | { 65 | if (! config('chronotrace.capture.http', true)) { 66 | return; 67 | } 68 | 69 | $this->traceRecorder->addCapturedData('http', [ 70 | 'type' => 'connection_failed', 71 | 'method' => $event->request->method(), 72 | 'url' => $this->scrubUrl($event->request->url()), 73 | 'timestamp' => microtime(true), 74 | ]); 75 | } 76 | 77 | /** 78 | * Nettoie l'URL pour masquer les données sensibles 79 | */ 80 | private function scrubUrl(string $url): string 81 | { 82 | // Masquer les tokens dans les URLs 83 | $patterns = [ 84 | '/([?&])token=([^&]+)/' => '$1token=[SCRUBBED]', 85 | '/([?&])api_key=([^&]+)/' => '$1api_key=[SCRUBBED]', 86 | '/([?&])access_token=([^&]+)/' => '$1access_token=[SCRUBBED]', 87 | ]; 88 | 89 | foreach ($patterns as $pattern => $replacement) { 90 | $result = preg_replace($pattern, $replacement, $url); 91 | if ($result !== null) { 92 | $url = $result; 93 | } 94 | } 95 | 96 | return $url; 97 | } 98 | 99 | /** 100 | * Nettoie les headers sensibles 101 | * 102 | * @param array $headers 103 | * 104 | * @return array 105 | */ 106 | private function scrubHeaders(array $headers): array 107 | { 108 | $sensitiveHeaders = [ 109 | 'authorization', 110 | 'x-api-key', 111 | 'x-auth-token', 112 | 'cookie', 113 | 'set-cookie', 114 | ]; 115 | 116 | $scrubbed = []; 117 | foreach ($headers as $name => $value) { 118 | $lowerName = strtolower($name); 119 | $scrubbed[$name] = in_array($lowerName, $sensitiveHeaders, true) ? '[SCRUBBED]' : $value; 120 | } 121 | 122 | return $scrubbed; 123 | } 124 | 125 | /** 126 | * Calcule la taille du body de la requête 127 | */ 128 | private function getBodySize(mixed $body): int 129 | { 130 | if (is_string($body)) { 131 | return strlen($body); 132 | } 133 | 134 | if (is_array($body)) { 135 | $encoded = json_encode($body); 136 | 137 | return $encoded !== false ? strlen($encoded) : 0; 138 | } 139 | 140 | return 0; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/Integration/ArtisanCommandsTest.php: -------------------------------------------------------------------------------- 1 | set('chronotrace.enabled', true); 25 | $app['config']->set('chronotrace.storage.disk', 'local'); 26 | $app['config']->set('chronotrace.storage.path', 'tests/empty-traces-' . uniqid()); 27 | } 28 | 29 | public function test_chronotrace_commands_are_registered(): void 30 | { 31 | $commands = [ 32 | 'chronotrace:list', 33 | 'chronotrace:purge', 34 | 'chronotrace:record', 35 | 'chronotrace:replay', 36 | ]; 37 | 38 | $kernel = $this->app->make(Kernel::class); 39 | $allCommands = $kernel->all(); 40 | 41 | foreach ($commands as $command) { 42 | $this->assertArrayHasKey($command, $allCommands, "Command {$command} should be registered"); 43 | } 44 | } 45 | 46 | public function test_list_command_works(): void 47 | { 48 | $this->artisan(ListCommand::class) 49 | ->expectsOutput('Listing stored traces...') 50 | ->assertExitCode(0); 51 | } 52 | 53 | public function test_purge_command_can_be_cancelled(): void 54 | { 55 | $this->artisan(PurgeCommand::class) 56 | ->expectsConfirmation('Delete traces older than 30 days?', 'no') 57 | ->expectsOutput('Purge cancelled.') 58 | ->assertExitCode(0); 59 | } 60 | 61 | public function test_purge_command_with_confirm_flag(): void 62 | { 63 | $this->artisan(PurgeCommand::class, ['--confirm' => true]) 64 | ->expectsOutput('Purging traces older than 30 days...') 65 | ->expectsOutput('Successfully purged 0 traces.') 66 | ->assertExitCode(0); 67 | } 68 | 69 | public function test_record_command_works_with_real_url(): void 70 | { 71 | $this->artisan(RecordCommand::class, ['url' => 'https://httpbin.org/get']) 72 | ->expectsOutput('Recording trace for GET https://httpbin.org/get...') 73 | ->expectsOutput('✅ Trace recorded successfully!') 74 | ->assertExitCode(0); 75 | } 76 | 77 | public function test_record_command_with_options(): void 78 | { 79 | $this->artisan(RecordCommand::class, [ 80 | 'url' => 'https://httpbin.org/post', 81 | '--method' => 'POST', 82 | '--data' => '{"name": "John"}', 83 | '--timeout' => '10', 84 | ]) 85 | ->expectsOutput('Recording trace for POST https://httpbin.org/post...') 86 | ->expectsOutput('✅ Trace recorded successfully!') 87 | ->assertExitCode(0); 88 | } 89 | 90 | public function test_replay_command_with_missing_trace(): void 91 | { 92 | $this->artisan(ReplayCommand::class, ['trace-id' => 'nonexistent']) 93 | ->expectsOutput('Replaying trace nonexistent...') 94 | ->expectsOutput('Trace nonexistent not found.') 95 | ->assertExitCode(1); 96 | } 97 | 98 | public function test_help_for_commands(): void 99 | { 100 | $commands = [ 101 | 'chronotrace:list', 102 | 'chronotrace:purge', 103 | 'chronotrace:record', 104 | 'chronotrace:replay', 105 | ]; 106 | 107 | foreach ($commands as $command) { 108 | // Juste vérifier que la commande existe et peut être exécutée 109 | $result = $this->artisan($command, ['--help' => true]); 110 | $result->assertExitCode(0); 111 | } 112 | } 113 | 114 | public function test_list_command_respects_limit_option(): void 115 | { 116 | $this->artisan(ListCommand::class, ['--limit' => '5']) 117 | ->expectsOutput('Listing stored traces...') 118 | ->assertExitCode(0); 119 | } 120 | 121 | public function test_purge_command_respects_days_option(): void 122 | { 123 | $this->artisan(PurgeCommand::class, ['--days' => '7', '--confirm' => true]) 124 | ->expectsOutput('Purging traces older than 7 days...') 125 | ->expectsOutput('Successfully purged 0 traces.') 126 | ->assertExitCode(0); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Commands/MiddlewareTestCommand.php: -------------------------------------------------------------------------------- 1 | info('🧪 Testing ChronoTrace Middleware Installation'); 19 | $this->newLine(); 20 | 21 | // 1. Vérifier la configuration 22 | $this->info('📋 Configuration Check:'); 23 | $this->checkConfigValue('enabled', config('chronotrace.enabled')); 24 | $this->checkConfigValue('mode', config('chronotrace.mode')); 25 | $this->checkConfigValue('debug', config('chronotrace.debug')); 26 | $this->newLine(); 27 | 28 | // 2. Vérifier l'enregistrement du middleware 29 | $this->info('🔧 Middleware Registration Check:'); 30 | $middlewareRegistered = $this->checkMiddlewareRegistration(); 31 | $this->newLine(); 32 | 33 | // 3. Suggestions 34 | $this->info('💡 Recommendations:'); 35 | 36 | if (! config('chronotrace.enabled')) { 37 | $this->warn(' - Enable ChronoTrace: Set CHRONOTRACE_ENABLED=true in .env'); 38 | } 39 | 40 | if (! config('chronotrace.debug')) { 41 | $this->warn(' - Enable debug mode: Set CHRONOTRACE_DEBUG=true in .env'); 42 | } 43 | 44 | if (! $middlewareRegistered) { 45 | $this->error(' - Middleware not properly registered!'); 46 | $this->line(' Add this to bootstrap/app.php:'); 47 | $this->line(' ```php'); 48 | $this->line(' ->withMiddleware(function (Middleware $middleware) {'); 49 | $this->line(' $middleware->web(append: ['); 50 | $this->line(' \\Grazulex\\LaravelChronotrace\\Middleware\\ChronoTraceMiddleware::class,'); 51 | $this->line(' ]);'); 52 | $this->line(' $middleware->api(append: ['); 53 | $this->line(' \\Grazulex\\LaravelChronotrace\\Middleware\\ChronoTraceMiddleware::class,'); 54 | $this->line(' ]);'); 55 | $this->line(' })'); 56 | $this->line(' ```'); 57 | } else { 58 | $this->info(' - Middleware is properly registered ✅'); 59 | } 60 | 61 | // 4. Test avec une requête simulée 62 | $this->info('🚀 Simulation Test:'); 63 | $this->testSimulatedRequest(); 64 | 65 | return Command::SUCCESS; 66 | } 67 | 68 | private function checkConfigValue(string $key, mixed $value): void 69 | { 70 | $displayValue = is_bool($value) ? ($value ? 'true' : 'false') : (is_scalar($value) ? (string) $value : 'non-scalar'); 71 | $this->line(" chronotrace.{$key}: {$displayValue}"); 72 | } 73 | 74 | private function checkMiddlewareRegistration(): bool 75 | { 76 | try { 77 | // Vérifier si le middleware peut être instancié 78 | $middleware = app(ChronoTraceMiddleware::class); 79 | $this->line(' ✅ Middleware class can be instantiated'); 80 | 81 | // Vérifier dans les routes (méthode approximative) 82 | $middlewareFound = false; 83 | 84 | // Note: En Laravel 11+, il est difficile de vérifier programmatiquement 85 | // l'enregistrement du middleware dans bootstrap/app.php 86 | // On fait une vérification basique 87 | 88 | $this->line(' ⚠️ Cannot programmatically verify middleware registration in Laravel 11+'); 89 | $this->line(' Please ensure it\'s added to bootstrap/app.php manually'); 90 | 91 | return true; // On assume que c'est OK si on peut instancier 92 | } catch (Exception $e) { 93 | $this->line(" ❌ Error instantiating middleware: {$e->getMessage()}"); 94 | 95 | return false; 96 | } 97 | } 98 | 99 | private function testSimulatedRequest(): void 100 | { 101 | try { 102 | // Simuler une requête simple 103 | $request = Request::create('/test', 'GET'); 104 | 105 | $this->line(' 📍 Simulating GET /test request...'); 106 | 107 | // Vérifier que le middleware peut traiter la requête 108 | $middleware = app(ChronoTraceMiddleware::class); 109 | 110 | $response = $middleware->handle($request, fn ($req) => response('Test response', 200)); 111 | 112 | if ($response->getStatusCode() === 200) { 113 | $this->line(' ✅ Middleware processed request successfully'); 114 | } else { 115 | $this->line(" ❌ Unexpected response status: {$response->getStatusCode()}"); 116 | } 117 | } catch (Exception $e) { 118 | $this->line(" ❌ Error in simulation: {$e->getMessage()}"); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Middleware/ChronoTraceMiddleware.php: -------------------------------------------------------------------------------- 1 | method()} {$request->fullUrl()}"); 31 | } 32 | 33 | // Vérifier si ChronoTrace est activé 34 | if (! config('chronotrace.enabled', false)) { 35 | if (config('chronotrace.debug', false)) { 36 | error_log('ChronoTrace Middleware: Disabled by config'); 37 | } 38 | 39 | return $next($request); 40 | } 41 | 42 | // Vérifier si on doit capturer cette requête 43 | if (! $this->shouldCapture($request)) { 44 | if (config('chronotrace.debug', false)) { 45 | error_log('ChronoTrace Middleware: Request not captured due to shouldCapture() = false'); 46 | } 47 | 48 | return $next($request); 49 | } 50 | 51 | if (config('chronotrace.debug', false)) { 52 | error_log('ChronoTrace Middleware: Starting capture'); 53 | } 54 | 55 | // Démarrer la capture (ultra-léger, juste marquage en mémoire) 56 | $traceId = $this->recorder->startCapture($request); 57 | 58 | if (config('chronotrace.debug', false)) { 59 | error_log("ChronoTrace Middleware: Started capture with traceId: {$traceId}"); 60 | } 61 | 62 | $startTime = microtime(true); 63 | $startMemory = memory_get_usage(true); 64 | 65 | try { 66 | /** @var SymfonyResponse $response */ 67 | $response = $next($request); 68 | 69 | // Calculer les métriques 70 | $duration = microtime(true) - $startTime; 71 | $memoryUsage = memory_get_usage(true) - $startMemory; 72 | 73 | // Finaliser la capture (toujours léger) 74 | $this->recorder->finishCapture($traceId, $response, $duration, $memoryUsage); 75 | 76 | return $response; 77 | } catch (Throwable $exception) { 78 | // En cas d'exception, capturer avec l'erreur 79 | $duration = microtime(true) - $startTime; 80 | $memoryUsage = memory_get_usage(true) - $startMemory; 81 | 82 | $this->recorder->finishCaptureWithException($traceId, $exception, $duration, $memoryUsage); 83 | 84 | throw $exception; 85 | } 86 | } 87 | 88 | /** 89 | * Détermine si cette requête doit être capturée 90 | */ 91 | private function shouldCapture(Request $request): bool 92 | { 93 | $mode = config('chronotrace.mode', 'record_on_error'); 94 | 95 | if (config('chronotrace.debug', false)) { 96 | $modeString = is_string($mode) ? $mode : 'unknown'; 97 | error_log("ChronoTrace Middleware: shouldCapture() mode: {$modeString}"); 98 | } 99 | 100 | $result = match ($mode) { 101 | 'always' => true, 102 | 'sample' => $this->shouldSample(), 103 | 'targeted' => $this->isTargetedRoute($request), 104 | 'record_on_error' => true, // On capture tout, on décidera plus tard si on stocke 105 | default => false, 106 | }; 107 | 108 | if (config('chronotrace.debug', false)) { 109 | $resultStr = $result ? 'true' : 'false'; 110 | error_log("ChronoTrace Middleware: shouldCapture() result: {$resultStr}"); 111 | } 112 | 113 | return $result; 114 | } 115 | 116 | /** 117 | * Échantillonnage probabiliste 118 | */ 119 | private function shouldSample(): bool 120 | { 121 | $sampleRate = config('chronotrace.sample_rate', 0.001); 122 | 123 | return mt_rand() / mt_getrandmax() < $sampleRate; 124 | } 125 | 126 | /** 127 | * Vérifie si la route est ciblée 128 | */ 129 | private function isTargetedRoute(Request $request): bool 130 | { 131 | $targetRoutes = config('chronotrace.targets.routes', []); 132 | if (! is_array($targetRoutes)) { 133 | return false; 134 | } 135 | 136 | $currentRoute = $request->path(); 137 | 138 | foreach ($targetRoutes as $pattern) { 139 | if (is_string($pattern) && fnmatch($pattern, $currentRoute)) { 140 | return true; 141 | } 142 | } 143 | 144 | return false; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/Integration/MiddlewareIntegrationTest.php: -------------------------------------------------------------------------------- 1 | set('chronotrace.enabled', true); 25 | $app['config']->set('chronotrace.mode', 'always'); 26 | $app['config']->set('chronotrace.async_storage', false); 27 | $app['config']->set('chronotrace.storage.disk', 'local'); 28 | } 29 | 30 | #[Override] 31 | protected function setUp(): void 32 | { 33 | parent::setUp(); 34 | 35 | // Définir des routes de test 36 | Route::get('/test', fn () => response()->json(['message' => 'Hello World']))->name('test.hello'); 37 | 38 | Route::get('/test/error', function (): void { 39 | throw new Exception('Test error'); 40 | })->name('test.error'); 41 | 42 | Route::post('/test/data', fn (Request $request) => response()->json([ 43 | 'received' => $request->all(), 44 | 'password' => 'should-be-scrubbed', 45 | ]))->name('test.data'); 46 | } 47 | 48 | public function test_middleware_is_automatically_registered(): void 49 | { 50 | $middleware = $this->app['router']->getMiddlewareGroups(); 51 | 52 | $this->assertContains(ChronoTraceMiddleware::class, $middleware['web']); 53 | $this->assertContains(ChronoTraceMiddleware::class, $middleware['api']); 54 | } 55 | 56 | public function test_middleware_captures_successful_requests(): void 57 | { 58 | $response = $this->get('/test'); 59 | 60 | $response->assertStatus(200); 61 | $response->assertJson(['message' => 'Hello World']); 62 | 63 | // Le middleware devrait avoir capturé cette requête 64 | // (Vérification plus approfondie nécessiterait l'accès au stockage) 65 | } 66 | 67 | public function test_middleware_captures_error_requests(): void 68 | { 69 | $response = $this->get('/test/error'); 70 | 71 | $response->assertStatus(500); 72 | 73 | // Le middleware devrait avoir capturé cette erreur 74 | } 75 | 76 | public function test_middleware_captures_post_requests_with_data(): void 77 | { 78 | $data = [ 79 | 'name' => 'John Doe', 80 | 'password' => 'secret123', 81 | 'email' => 'john@example.com', 82 | ]; 83 | 84 | $response = $this->post('/test/data', $data); 85 | 86 | $response->assertStatus(200); 87 | $response->assertJsonFragment(['received' => $data]); 88 | 89 | // Le middleware devrait avoir capturé et nettoyé les données sensibles 90 | } 91 | 92 | public function test_middleware_can_be_disabled(): void 93 | { 94 | // Désactiver ChronoTrace 95 | config(['chronotrace.enabled' => false]); 96 | 97 | $response = $this->get('/test'); 98 | 99 | $response->assertStatus(200); 100 | 101 | // Aucune trace ne devrait être capturée 102 | } 103 | 104 | public function test_middleware_respects_sample_mode(): void 105 | { 106 | config(['chronotrace.mode' => 'sample']); 107 | config(['chronotrace.sample_rate' => 0.0]); // 0% échantillonnage 108 | 109 | $response = $this->get('/test'); 110 | 111 | $response->assertStatus(200); 112 | 113 | // Aucune trace ne devrait être capturée avec 0% d'échantillonnage 114 | } 115 | 116 | public function test_middleware_respects_record_on_error_mode(): void 117 | { 118 | config(['chronotrace.mode' => 'record_on_error']); 119 | 120 | // Requête réussie - ne devrait pas être tracée 121 | $response = $this->get('/test'); 122 | $response->assertStatus(200); 123 | 124 | // Requête avec erreur - devrait être tracée 125 | $errorResponse = $this->get('/test/error'); 126 | $errorResponse->assertStatus(500); 127 | } 128 | 129 | public function test_middleware_handles_targeted_routes(): void 130 | { 131 | config(['chronotrace.mode' => 'targeted']); 132 | config(['chronotrace.targeted_routes' => ['/test']]); 133 | 134 | // Route ciblée - devrait être tracée 135 | $response = $this->get('/test'); 136 | $response->assertStatus(200); 137 | 138 | // Route non ciblée - ne devrait pas être tracée 139 | // (nécessiterait une autre route pour tester) 140 | } 141 | 142 | public function test_middleware_preserves_response(): void 143 | { 144 | $originalResponse = $this->get('/test'); 145 | 146 | // Le middleware ne devrait pas modifier la réponse 147 | $originalResponse->assertStatus(200); 148 | $originalResponse->assertJson(['message' => 'Hello World']); 149 | 150 | // Headers devraient être préservés 151 | $this->assertSame('application/json', $originalResponse->headers->get('Content-Type')); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Commands/RecordCommand.php: -------------------------------------------------------------------------------- 1 | argument('url'); 21 | $method = strtoupper($this->option('method') ?? 'GET'); 22 | $timeout = (int) $this->option('timeout'); 23 | 24 | // Parse les données JSON si fournies 25 | $data = []; 26 | $dataOption = $this->option('data'); 27 | if ($dataOption !== null) { 28 | $dataJson = json_decode((string) $dataOption, true); 29 | if (json_last_error() !== JSON_ERROR_NONE) { 30 | $this->error('Invalid JSON in --data option'); 31 | 32 | return Command::FAILURE; 33 | } 34 | if (is_array($dataJson)) { 35 | $data = $dataJson; 36 | } 37 | } 38 | 39 | // Parse les headers JSON si fournis 40 | $headers = []; 41 | $headersOption = $this->option('headers'); 42 | if ($headersOption !== null) { 43 | $headersJson = json_decode((string) $headersOption, true); 44 | if (json_last_error() !== JSON_ERROR_NONE) { 45 | $this->error('Invalid JSON in --headers option'); 46 | 47 | return Command::FAILURE; 48 | } 49 | if (is_array($headersJson)) { 50 | $headers = $headersJson; 51 | } 52 | } 53 | 54 | $this->info("Recording trace for {$method} {$url}..."); 55 | 56 | try { 57 | // Créer une requête simulée pour le TraceRecorder 58 | $content = $data === [] ? null : json_encode($data); 59 | if ($content === false) { 60 | $content = null; 61 | } 62 | $request = Request::create($url, $method, $data, [], [], [], $content); 63 | 64 | // Ajouter les headers de manière sécurisée 65 | foreach ($headers as $key => $value) { 66 | if (is_string($key) && is_string($value)) { 67 | $request->headers->set($key, $value); 68 | } 69 | } 70 | 71 | // Démarrer la capture 72 | $traceId = $recorder->startCapture($request); 73 | $startTime = microtime(true); 74 | $startMemory = memory_get_usage(true); 75 | 76 | // Faire la requête HTTP 77 | $httpClient = Http::timeout($timeout); 78 | if ($headers !== []) { 79 | $headerStrings = []; 80 | foreach ($headers as $key => $value) { 81 | if (is_string($key) && is_string($value)) { 82 | $headerStrings[$key] = $value; 83 | } 84 | } 85 | $httpClient = $httpClient->withHeaders($headerStrings); 86 | } 87 | 88 | $httpResponse = match ($method) { 89 | 'GET' => $httpClient->get($url), 90 | 'POST' => $httpClient->post($url, $data), 91 | 'PUT' => $httpClient->put($url, $data), 92 | 'PATCH' => $httpClient->patch($url, $data), 93 | 'DELETE' => $httpClient->delete($url), 94 | default => $httpClient->get($url) 95 | }; 96 | 97 | // Calculer les métriques 98 | $duration = microtime(true) - $startTime; 99 | $memoryUsage = memory_get_usage(true) - $startMemory; 100 | 101 | // Créer une réponse Symfony compatible 102 | $response = new Response( 103 | $httpResponse->body(), 104 | $httpResponse->status(), 105 | $httpResponse->headers() 106 | ); 107 | 108 | // Finaliser la capture 109 | $recorder->finishCapture($traceId, $response, $duration, $memoryUsage); 110 | 111 | $this->info('✅ Trace recorded successfully!'); 112 | $this->line("📊 Status: {$httpResponse->status()}"); 113 | $this->line('⏱️ Duration: ' . number_format($duration * 1000, 2) . 'ms'); 114 | $this->line('💾 Memory: ' . number_format($memoryUsage / 1024, 2) . 'KB'); 115 | $this->line("🆔 Trace ID: {$traceId}"); 116 | 117 | if ($httpResponse->failed()) { 118 | $this->warn("⚠️ HTTP request failed with status {$httpResponse->status()}"); 119 | } 120 | } catch (Throwable $e) { 121 | // En cas d'exception, capturer avec l'erreur si on a un traceId 122 | if (isset($traceId) && isset($startTime) && isset($startMemory)) { 123 | $duration = microtime(true) - $startTime; 124 | $memoryUsage = memory_get_usage(true) - $startMemory; 125 | $recorder->finishCaptureWithException($traceId, $e, $duration, $memoryUsage); 126 | } 127 | 128 | $this->error("❌ Failed to record trace: {$e->getMessage()}"); 129 | 130 | return Command::FAILURE; 131 | } 132 | 133 | return Command::SUCCESS; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/Integration/TraceRecorderIntegrationTest.php: -------------------------------------------------------------------------------- 1 | set('chronotrace.enabled', true); 27 | $app['config']->set('chronotrace.mode', 'always'); 28 | $app['config']->set('chronotrace.async_storage', false); // Test synchrone 29 | $app['config']->set('chronotrace.storage.disk', 'local'); 30 | $app['config']->set('chronotrace.storage.path', 'tests/traces'); 31 | $app['config']->set('chronotrace.scrub', ['password', 'token']); 32 | } 33 | 34 | protected function defineDatabaseMigrations(): void 35 | { 36 | // Si on avait des migrations pour ChronoTrace 37 | } 38 | 39 | public function test_trace_recorder_can_be_resolved(): void 40 | { 41 | $recorder = $this->app->make(TraceRecorder::class); 42 | 43 | $this->assertInstanceOf(TraceRecorder::class, $recorder); 44 | } 45 | 46 | public function test_trace_storage_can_be_resolved(): void 47 | { 48 | $storage = $this->app->make(TraceStorage::class); 49 | 50 | $this->assertInstanceOf(TraceStorage::class, $storage); 51 | } 52 | 53 | public function test_can_start_and_finish_trace_capture(): void 54 | { 55 | $recorder = $this->app->make(TraceRecorder::class); 56 | 57 | // Créer une fausse requête 58 | $request = Request::create('/test', 'GET', ['param' => 'value']); 59 | $request->headers->set('User-Agent', 'TestAgent/1.0'); 60 | 61 | // Démarrer la capture 62 | $traceId = $recorder->startCapture($request); 63 | 64 | $this->assertNotEmpty($traceId); 65 | $this->assertStringStartsWith('ct_', $traceId); 66 | 67 | // Vérifier que le traceId est en instance 68 | $currentTrace = $this->app->make('chronotrace.current_trace'); 69 | $this->assertSame($traceId, $currentTrace); 70 | 71 | // Créer une fausse réponse 72 | $response = new Response('{"status": "ok"}', 200, ['Content-Type' => 'application/json']); 73 | 74 | // Finaliser la capture 75 | $recorder->finishCapture($traceId, $response, 0.1, 1024000); 76 | 77 | // Vérifier que l'instance a été nettoyée 78 | $this->expectException(BindingResolutionException::class); 79 | $this->app->make('chronotrace.current_trace'); 80 | } 81 | 82 | public function test_can_capture_with_exception(): void 83 | { 84 | $recorder = $this->app->make(TraceRecorder::class); 85 | 86 | $request = Request::create('/error', 'GET'); 87 | $traceId = $recorder->startCapture($request); 88 | 89 | $exception = new Exception('Test exception', 500); 90 | 91 | $recorder->finishCaptureWithException($traceId, $exception, 0.2, 2048000); 92 | 93 | // Devrait avoir stocké la trace avec exception 94 | $this->assertTrue(true); // Test basique pour l'instant 95 | } 96 | 97 | public function test_can_add_captured_data(): void 98 | { 99 | $recorder = $this->app->make(TraceRecorder::class); 100 | 101 | $request = Request::create('/test', 'GET'); 102 | $traceId = $recorder->startCapture($request); 103 | 104 | // Ajouter des données capturées 105 | $recorder->addCapturedData('database', [ 106 | 'query' => 'SELECT * FROM users', 107 | 'time' => 0.001, 108 | 'bindings' => [], 109 | ]); 110 | 111 | $recorder->addCapturedData('cache', [ 112 | 'operation' => 'get', 113 | 'key' => 'user:1', 114 | 'hit' => true, 115 | ]); 116 | 117 | $response = new Response('OK', 200); 118 | $recorder->finishCapture($traceId, $response, 0.1, 1024000); 119 | 120 | $this->assertTrue(true); // Test basique pour l'instant 121 | } 122 | 123 | public function test_configuration_is_loaded(): void 124 | { 125 | $this->assertTrue(config('chronotrace.enabled')); 126 | $this->assertSame('always', config('chronotrace.mode')); 127 | $this->assertFalse(config('chronotrace.async_storage')); 128 | $this->assertIsArray(config('chronotrace.scrub')); 129 | $this->assertContains('password', config('chronotrace.scrub')); 130 | } 131 | 132 | public function test_middleware_is_registered(): void 133 | { 134 | $router = $this->app->make('router'); 135 | 136 | // Vérifier que le middleware est enregistré 137 | $middleware = $router->getMiddlewareGroups(); 138 | 139 | $this->assertArrayHasKey('web', $middleware); 140 | $this->assertArrayHasKey('api', $middleware); 141 | 142 | // Le middleware ChronoTrace devrait être dans les groupes 143 | $this->assertContains( 144 | ChronoTraceMiddleware::class, 145 | $middleware['web'] 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | coc. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/Commands/InstallCommand.php: -------------------------------------------------------------------------------- 1 | info('Installing ChronoTrace...'); 17 | 18 | // 1. Publier la configuration 19 | $this->call('vendor:publish', [ 20 | '--tag' => 'chronotrace-config', 21 | '--force' => $this->option('force'), 22 | ]); 23 | 24 | // 2. Vérifier la version de Laravel 25 | $laravelVersion = $this->getLaravelVersion(); 26 | 27 | if (version_compare($laravelVersion, '11.0', '>=')) { 28 | $this->installForLaravel11(); 29 | } else { 30 | $this->installForLaravelLegacy(); 31 | } 32 | 33 | $this->info('✅ ChronoTrace installation completed!'); 34 | $this->newLine(); 35 | $this->info('🚀 You can now start using ChronoTrace:'); 36 | $this->line(' php artisan chronotrace:list'); 37 | $this->line(' php artisan chronotrace:record https://example.com'); 38 | $this->line(' php artisan chronotrace:test-internal # Test internal operations'); 39 | $this->line(' php artisan chronotrace:diagnose # Check configuration'); 40 | $this->newLine(); 41 | $this->info('📖 For more help, check the documentation at:'); 42 | $this->line(' https://github.com/Grazulex/laravel-chronotrace'); 43 | 44 | return Command::SUCCESS; 45 | } 46 | 47 | private function installForLaravel11(): void 48 | { 49 | $this->info('📱 Detected Laravel 11+ - Configuring bootstrap/app.php...'); 50 | 51 | $bootstrapPath = base_path('bootstrap/app.php'); 52 | 53 | if (! File::exists($bootstrapPath)) { 54 | $this->error('❌ bootstrap/app.php not found!'); 55 | 56 | return; 57 | } 58 | 59 | $content = File::get($bootstrapPath); 60 | 61 | // Vérifier si le middleware est déjà configuré 62 | if (str_contains($content, 'ChronoTraceMiddleware')) { 63 | $this->warn('⚠️ ChronoTrace middleware already configured in bootstrap/app.php'); 64 | 65 | return; 66 | } 67 | 68 | // Rechercher le pattern withMiddleware 69 | if (preg_match('/->withMiddleware\(function \(([^)]+)\) use \([^)]*\)[^{]*{/', $content)) { 70 | // Pattern avec use() 71 | $middlewareCode = <<<'PHP' 72 | $middleware->web(append: [ 73 | \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class, 74 | ]); 75 | $middleware->api(append: [ 76 | \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class, 77 | ]); 78 | PHP; 79 | } elseif (preg_match('/->withMiddleware\(function \(([^)]+)\)[^{]*{/', $content)) { 80 | // Pattern simple 81 | $middlewareCode = <<<'PHP' 82 | $middleware->web(append: [ 83 | \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class, 84 | ]); 85 | $middleware->api(append: [ 86 | \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class, 87 | ]); 88 | PHP; 89 | } else { 90 | // Pas de withMiddleware trouvé 91 | $this->showManualInstructions(); 92 | 93 | return; 94 | } 95 | 96 | // Injection du middleware 97 | $pattern = '/(\->withMiddleware\(function \([^)]+\)(?:\s+use\s*\([^)]*\))?\s*{\s*)/'; 98 | $replacement = '$1' . "\n" . $middlewareCode . "\n"; 99 | 100 | $newContent = preg_replace($pattern, $replacement, $content); 101 | 102 | if ($newContent && $newContent !== $content) { 103 | File::put($bootstrapPath, $newContent); 104 | $this->info('✅ ChronoTrace middleware automatically added to bootstrap/app.php'); 105 | } else { 106 | $this->showManualInstructions(); 107 | } 108 | } 109 | 110 | private function installForLaravelLegacy(): void 111 | { 112 | $this->info('📱 Detected Laravel <11 - Middleware will be auto-registered'); 113 | $this->info('✅ No additional configuration needed!'); 114 | } 115 | 116 | private function showManualInstructions(): void 117 | { 118 | $this->warn('⚠️ Could not automatically configure middleware.'); 119 | $this->info('📝 Please add this to your bootstrap/app.php manually:'); 120 | $this->newLine(); 121 | 122 | $this->line('->withMiddleware(function (Middleware $middleware) {'); 123 | $this->line(' $middleware->web(append: ['); 124 | $this->line(' \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class,'); 125 | $this->line(' ]);'); 126 | $this->line(' $middleware->api(append: ['); 127 | $this->line(' \Grazulex\LaravelChronotrace\Middleware\ChronoTraceMiddleware::class,'); 128 | $this->line(' ]);'); 129 | $this->line('})'); 130 | $this->newLine(); 131 | $this->info('💡 After adding the middleware, test with:'); 132 | $this->line(' php artisan chronotrace:test-internal'); 133 | $this->line(' php artisan chronotrace:diagnose'); 134 | } 135 | 136 | private function getLaravelVersion(): string 137 | { 138 | return $this->laravel->version(); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /tests/Integration/MiddlewareResponseTypesTest.php: -------------------------------------------------------------------------------- 1 | traceRecorder = app(TraceRecorder::class); 33 | } 34 | 35 | /** 36 | * Test exact du cas qui causait le TypeError 37 | * Reproduit un workflow complet de capture avec JsonResponse 38 | */ 39 | public function test_full_capture_cycle_with_json_response(): void 40 | { 41 | $request = Request::create('/api/test', 'GET', ['param' => 'value']); 42 | $request->headers->set('Accept', 'application/json'); 43 | 44 | // Démarrage de la capture 45 | $traceId = $this->traceRecorder->startCapture($request); 46 | 47 | // Simulation d'un traitement (mesure du temps et mémoire) 48 | $startTime = microtime(true); 49 | $startMemory = memory_get_usage(true); 50 | 51 | // Simulation d'une réponse API typique 52 | $response = new JsonResponse([ 53 | 'status' => 'success', 54 | 'data' => [ 55 | 'id' => 123, 56 | 'name' => 'Test Item', 57 | 'created_at' => now()->toISOString(), 58 | ], 59 | 'meta' => [ 60 | 'version' => '1.0', 61 | 'timestamp' => time(), 62 | ], 63 | ], 200, [ 64 | 'Content-Type' => 'application/json', 65 | 'X-API-Version' => '1.0', 66 | ]); 67 | 68 | $duration = microtime(true) - $startTime; 69 | $memoryUsage = memory_get_usage(true) - $startMemory; 70 | 71 | // Ceci ne devrait plus lever de TypeError 72 | $this->traceRecorder->finishCapture($traceId, $response, $duration, $memoryUsage); 73 | 74 | $this->assertTrue(true); // Si on arrive ici, pas de TypeError 75 | } 76 | 77 | public function test_full_capture_cycle_with_redirect_response(): void 78 | { 79 | $request = Request::create('/login', 'POST', ['email' => 'test@example.com']); 80 | $traceId = $this->traceRecorder->startCapture($request); 81 | 82 | // Simulation d'une redirection après login 83 | $response = new RedirectResponse('/dashboard', 302, [ 84 | 'Location' => '/dashboard', 85 | 'Set-Cookie' => 'session=abc123; HttpOnly', 86 | ]); 87 | 88 | $this->traceRecorder->finishCapture($traceId, $response, 0.05, 2048); 89 | 90 | $this->assertTrue(true); 91 | } 92 | 93 | public function test_full_capture_cycle_with_html_response(): void 94 | { 95 | $request = Request::create('/home', 'GET'); 96 | $traceId = $this->traceRecorder->startCapture($request); 97 | 98 | // Simulation d'une réponse HTML 99 | $response = new Response( 100 | 'Home

Welcome

', 101 | 200, 102 | ['Content-Type' => 'text/html; charset=UTF-8'] 103 | ); 104 | 105 | $this->traceRecorder->finishCapture($traceId, $response, 0.1, 4096); 106 | 107 | $this->assertTrue(true); 108 | } 109 | 110 | /** 111 | * Test de stress : plusieurs types de réponses en séquence 112 | */ 113 | public function test_multiple_captures_with_different_response_types(): void 114 | { 115 | // 1. JSON API Response 116 | $jsonRequest = Request::create('/api/users', 'GET'); 117 | $jsonTraceId = $this->traceRecorder->startCapture($jsonRequest); 118 | $jsonResponse = new JsonResponse(['users' => []]); 119 | $this->traceRecorder->finishCapture($jsonTraceId, $jsonResponse, 0.02, 1024); 120 | 121 | // 2. Redirect Response 122 | $redirectRequest = Request::create('/old-url', 'GET'); 123 | $redirectTraceId = $this->traceRecorder->startCapture($redirectRequest); 124 | $redirectResponse = new RedirectResponse('/new-url', 301); 125 | $this->traceRecorder->finishCapture($redirectTraceId, $redirectResponse, 0.01, 512); 126 | 127 | // 3. HTML Response 128 | $htmlRequest = Request::create('/page', 'GET'); 129 | $htmlTraceId = $this->traceRecorder->startCapture($htmlRequest); 130 | $htmlResponse = new Response('

Page

'); 131 | $this->traceRecorder->finishCapture($htmlTraceId, $htmlResponse, 0.08, 2048); 132 | 133 | // 4. Custom Response 134 | $customRequest = Request::create('/custom', 'GET'); 135 | $customTraceId = $this->traceRecorder->startCapture($customRequest); 136 | $customResponse = new class extends \Symfony\Component\HttpFoundation\Response 137 | { 138 | public function __construct() 139 | { 140 | parent::__construct('Custom Content', 200, ['X-Custom' => 'true']); 141 | } 142 | }; 143 | $this->traceRecorder->finishCapture($customTraceId, $customResponse, 0.03, 768); 144 | 145 | $this->assertTrue(true); // Si toutes les captures passent, le problème est résolu 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/Integration/MiddlewareResponseFixTest.php: -------------------------------------------------------------------------------- 1 | new JsonResponse([ 45 | 'status' => 'success', 46 | 'data' => ['id' => 123, 'name' => 'Test'], 47 | 'meta' => ['timestamp' => time()], 48 | ], 200, ['Content-Type' => 'application/json'])); 49 | 50 | // Cette opération ne doit pas lever de TypeError 51 | $response = $middleware->handle($request, $next); 52 | 53 | $this->assertInstanceOf(JsonResponse::class, $response); 54 | $this->assertSame(200, $response->getStatusCode()); 55 | $this->assertJson($response->getContent()); 56 | } 57 | 58 | public function test_middleware_accepts_redirect_response_without_type_error(): void 59 | { 60 | $traceRecorder = app(TraceRecorder::class); 61 | $middleware = new ChronoTraceMiddleware($traceRecorder); 62 | 63 | $request = Request::create('/login', 'POST'); 64 | 65 | $next = (fn (): RedirectResponse => new RedirectResponse('/dashboard', 302, [ 66 | 'Location' => '/dashboard', 67 | 'Cache-Control' => 'no-cache', 68 | ])); 69 | 70 | $response = $middleware->handle($request, $next); 71 | 72 | $this->assertInstanceOf(RedirectResponse::class, $response); 73 | $this->assertSame(302, $response->getStatusCode()); 74 | $this->assertTrue($response->isRedirect('/dashboard')); 75 | } 76 | 77 | public function test_middleware_accepts_standard_response_without_type_error(): void 78 | { 79 | $traceRecorder = app(TraceRecorder::class); 80 | $middleware = new ChronoTraceMiddleware($traceRecorder); 81 | 82 | $request = Request::create('/page', 'GET'); 83 | 84 | $next = (fn (): Response => new Response( 85 | 'Test

Hello World

', 86 | 200, 87 | ['Content-Type' => 'text/html; charset=UTF-8'] 88 | )); 89 | 90 | $response = $middleware->handle($request, $next); 91 | 92 | $this->assertInstanceOf(Response::class, $response); 93 | $this->assertSame(200, $response->getStatusCode()); 94 | $this->assertStringContainsString('Hello World', $response->getContent()); 95 | } 96 | 97 | public function test_middleware_works_with_custom_symfony_response(): void 98 | { 99 | $traceRecorder = app(TraceRecorder::class); 100 | $middleware = new ChronoTraceMiddleware($traceRecorder); 101 | 102 | $request = Request::create('/custom', 'GET'); 103 | 104 | $next = (fn (): \Symfony\Component\HttpFoundation\Response => new class extends \Symfony\Component\HttpFoundation\Response 105 | { 106 | public function __construct() 107 | { 108 | parent::__construct('Custom Response Content', 201, [ 109 | 'X-Custom-Header' => 'test-value', 110 | 'Content-Type' => 'text/plain', 111 | ]); 112 | } 113 | }); 114 | 115 | $response = $middleware->handle($request, $next); 116 | 117 | $this->assertSame(201, $response->getStatusCode()); 118 | $this->assertSame('Custom Response Content', $response->getContent()); 119 | $this->assertSame('test-value', $response->headers->get('X-Custom-Header')); 120 | } 121 | 122 | /** 123 | * Test de régression : s'assurer qu'on peut traiter plusieurs types de réponses de suite 124 | */ 125 | public function test_middleware_handles_mixed_response_types_in_sequence(): void 126 | { 127 | $traceRecorder = app(TraceRecorder::class); 128 | $middleware = new ChronoTraceMiddleware($traceRecorder); 129 | 130 | // 1. JSON Response 131 | $jsonRequest = Request::create('/api/users', 'GET'); 132 | $jsonNext = fn (): JsonResponse => new JsonResponse(['users' => []], 200); 133 | $jsonResponse = $middleware->handle($jsonRequest, $jsonNext); 134 | $this->assertInstanceOf(JsonResponse::class, $jsonResponse); 135 | 136 | // 2. Redirect Response 137 | $redirectRequest = Request::create('/old-path', 'GET'); 138 | $redirectNext = fn (): RedirectResponse => new RedirectResponse('/new-path', 301); 139 | $redirectResponse = $middleware->handle($redirectRequest, $redirectNext); 140 | $this->assertInstanceOf(RedirectResponse::class, $redirectResponse); 141 | 142 | // 3. HTML Response 143 | $htmlRequest = Request::create('/home', 'GET'); 144 | $htmlNext = fn (): Response => new Response('

Home

', 200); 145 | $htmlResponse = $middleware->handle($htmlRequest, $htmlNext); 146 | $this->assertInstanceOf(Response::class, $htmlResponse); 147 | 148 | // Tous doivent avoir passé sans erreur 149 | $this->assertTrue(true); 150 | } 151 | } 152 | --------------------------------------------------------------------------------