├── .phpunit-watcher.yml ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer-require-checker.json ├── composer.json ├── config ├── bootstrap.php ├── di-console.php ├── di-providers.php ├── di-web.php ├── di.php ├── events-console.php ├── events-web.php └── params.php ├── infection.json.dist ├── psalm.xml ├── rector.php ├── src ├── Collector │ ├── CollectorInterface.php │ ├── CollectorTrait.php │ ├── Console │ │ ├── CommandCollector.php │ │ └── ConsoleAppInfoCollector.php │ ├── ContainerInterfaceProxy.php │ ├── ContainerProxyConfig.php │ ├── EventCollector.php │ ├── EventDispatcherInterfaceProxy.php │ ├── ExceptionCollector.php │ ├── HttpClientCollector.php │ ├── HttpClientInterfaceProxy.php │ ├── LogCollector.php │ ├── LoggerInterfaceProxy.php │ ├── ProxyLogTrait.php │ ├── ServiceCollector.php │ ├── ServiceMethodProxy.php │ ├── ServiceProxy.php │ ├── Stream │ │ ├── FilesystemStreamCollector.php │ │ ├── FilesystemStreamProxy.php │ │ ├── HttpStreamCollector.php │ │ └── HttpStreamProxy.php │ ├── SummaryCollectorInterface.php │ ├── TimelineCollector.php │ ├── VarDumperCollector.php │ ├── VarDumperHandlerInterfaceProxy.php │ └── Web │ │ ├── RequestCollector.php │ │ └── WebAppInfoCollector.php ├── Command │ └── DebugResetCommand.php ├── DataNormalizer.php ├── DebugServiceProvider.php ├── Debugger.php ├── Event │ └── ProxyMethodCallEvent.php ├── Helper │ ├── BacktraceMatcher.php │ └── StreamWrapper │ │ ├── StreamWrapper.php │ │ └── StreamWrapperInterface.php ├── ProxyDecoratedCalls.php ├── StartupPolicy │ ├── Collector │ │ ├── AllowAllCollectorPolicy.php │ │ ├── BlackListCollectorPolicy.php │ │ ├── CallableCollectorPolicy.php │ │ ├── CollectorStartupPolicyInterface.php │ │ └── WhiteListCollectorPolicy.php │ ├── Condition │ │ ├── CommandNameCondition.php │ │ ├── ConditionInterface.php │ │ ├── EnvironmentVariableCondition.php │ │ ├── HeaderCondition.php │ │ ├── PredefinedCondition.php │ │ └── UriPathCondition.php │ └── Debugger │ │ ├── AllowDebuggerPolicy.php │ │ ├── AlwaysOnDebuggerPolicy.php │ │ ├── CallableDebuggerPolicy.php │ │ ├── DebuggerStartupPolicyInterface.php │ │ └── DenyDebuggerPolicy.php └── Storage │ ├── FileStorage.php │ ├── MemoryStorage.php │ └── StorageInterface.php └── tests ├── Shared └── AbstractCollectorTestCase.php └── Support ├── Stub ├── BrokenProxyImplementation.php ├── Implementation1.php ├── Implementation2.php ├── Interface1.php ├── Interface2.php ├── PhpStreamProxy.php └── ThreeProperties.php └── StubCollector.php /.phpunit-watcher.yml: -------------------------------------------------------------------------------- 1 | watch: 2 | directories: 3 | - src 4 | - tests 5 | fileMask: '*.php' 6 | notifications: 7 | passingTests: false 8 | failingTests: false 9 | phpunit: 10 | binaryPath: vendor/bin/phpunit 11 | timeout: 180 12 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr12 2 | risky: true 3 | 4 | version: 8.1 5 | 6 | finder: 7 | exclude: 8 | - docs 9 | - vendor 10 | 11 | enabled: 12 | - alpha_ordered_traits 13 | - array_indentation 14 | - array_push 15 | - combine_consecutive_issets 16 | - combine_consecutive_unsets 17 | - combine_nested_dirname 18 | - declare_strict_types 19 | - dir_constant 20 | - fully_qualified_strict_types 21 | - function_to_constant 22 | - hash_to_slash_comment 23 | - is_null 24 | - logical_operators 25 | - magic_constant_casing 26 | - magic_method_casing 27 | - method_separation 28 | - modernize_types_casting 29 | - native_function_casing 30 | - native_function_type_declaration_casing 31 | - no_alias_functions 32 | - no_empty_comment 33 | - no_empty_phpdoc 34 | - no_empty_statement 35 | - no_extra_block_blank_lines 36 | - no_short_bool_cast 37 | - no_superfluous_elseif 38 | - no_unneeded_control_parentheses 39 | - no_unneeded_curly_braces 40 | - no_unneeded_final_method 41 | - no_unset_cast 42 | - no_unused_imports 43 | - no_unused_lambda_imports 44 | - no_useless_else 45 | - no_useless_return 46 | - normalize_index_brace 47 | - php_unit_dedicate_assert 48 | - php_unit_dedicate_assert_internal_type 49 | - php_unit_expectation 50 | - php_unit_mock 51 | - php_unit_mock_short_will_return 52 | - php_unit_namespaced 53 | - php_unit_no_expectation_annotation 54 | - phpdoc_no_empty_return 55 | - phpdoc_no_useless_inheritdoc 56 | - phpdoc_order 57 | - phpdoc_property 58 | - phpdoc_scalar 59 | - phpdoc_singular_inheritdoc 60 | - phpdoc_trim 61 | - phpdoc_trim_consecutive_blank_line_separation 62 | - phpdoc_type_to_var 63 | - phpdoc_types 64 | - phpdoc_types_order 65 | - print_to_echo 66 | - regular_callable_call 67 | - return_assignment 68 | - self_accessor 69 | - self_static_accessor 70 | - set_type_to_cast 71 | - short_array_syntax 72 | - short_list_syntax 73 | - simplified_if_return 74 | - single_quote 75 | - standardize_not_equals 76 | - ternary_to_null_coalescing 77 | - trailing_comma_in_multiline_array 78 | - unalign_double_arrow 79 | - unalign_equals 80 | - empty_loop_body_braces 81 | - integer_literal_case 82 | - union_type_without_spaces 83 | 84 | disabled: 85 | - function_declaration 86 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Yii Debug Extension Change Log 2 | 3 | ## 1.0.0 under development 4 | 5 | - Initial release. 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008 by Yii Software () 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 |

Yii Debug Extension

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/yii-debug/v)](https://packagist.org/packages/yiisoft/yii-debug) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/yii-debug/downloads)](https://packagist.org/packages/yiisoft/yii-debug) 11 | [![Build status](https://github.com/yiisoft/yii-debug/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/yii-debug/actions/workflows/build.yml) 12 | [![Code coverage](https://codecov.io/gh/yiisoft/yii-debug/graph/badge.svg?token=6FGTORDAP0)](https://codecov.io/gh/yiisoft/yii-debug) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fyii-debug%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-debug/master) 14 | [![static analysis](https://github.com/yiisoft/yii-debug/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/yii-debug/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/yii-debug/coverage.svg)](https://shepherd.dev/github/yiisoft/yii-debug) 16 | 17 | This extension provides a debugger for [Yii framework](https://www.yiiframework.com) applications. When this extension is used, 18 | a debugger toolbar will appear at the bottom of every page. The extension also provides 19 | a set of standalone pages to display more detailed debug information. 20 | 21 | ## Requirements 22 | 23 | - PHP 8.1 or higher. 24 | 25 | ## Installation 26 | 27 | The package could be installed with [Composer](https://getcomposer.org): 28 | 29 | ```shell 30 | composer require yiisoft/yii-debug --dev 31 | ``` 32 | 33 | > The debug extension also can be installed without the `--dev` flag if you want to collect data in production. 34 | > Specify the necessary collectors only to reduce functions overriding and improve performance. 35 | 36 | ## General usage 37 | 38 | Once the extension is installed, modify your `config/common/params.php` as follows: 39 | 40 | ```php 41 | return [ 42 | 'yiisoft/yii-debug' => [ 43 | 'enabled' => true, 44 | ], 45 | // ... 46 | ]; 47 | ``` 48 | 49 | All included collectors start listen and collect payloads from each HTTP request or console run. 50 | 51 | Install both [`yiisoft/yii-debug-api`](https://github.com/yiisoft/yii-debug-api) and [`yiisoft/yii-dev-panel`](https://github.com/yiisoft/yii-dev-panel) 52 | to be able to interact with collected data through UI. 53 | 54 | ## Documentation 55 | 56 | - [Guide](docs/guide/en/README.md) 57 | - [Internals](docs/internals.md) 58 | 59 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 60 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 61 | 62 | ## License 63 | 64 | The Yii Debug Extension is free software. It is released under the terms of the BSD License. 65 | Please see [`LICENSE`](./LICENSE.md) for more information. 66 | 67 | Maintained by [Yii Software](https://www.yiiframework.com/). 68 | 69 | ## Support the project 70 | 71 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 72 | 73 | ## Follow updates 74 | 75 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 76 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 77 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 78 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 79 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 80 | -------------------------------------------------------------------------------- /composer-require-checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist": [ 3 | "Yiisoft\\Definitions\\ArrayDefinition", 4 | "Yiisoft\\Definitions\\CallableDefinition", 5 | "Yiisoft\\Definitions\\ValueDefinition", 6 | "Yiisoft\\Middleware\\Dispatcher\\Event\\BeforeMiddleware", 7 | "Yiisoft\\Yii\\Console\\Event\\ApplicationShutdown", 8 | "Yiisoft\\Yii\\Console\\Event\\ApplicationStartup", 9 | "Yiisoft\\Yii\\Console\\ExitCode", 10 | "Yiisoft\\Yii\\Console\\Output\\ConsoleBufferedOutput", 11 | "Yiisoft\\Yii\\Http\\Event\\AfterEmit", 12 | "Yiisoft\\Yii\\Http\\Event\\AfterRequest", 13 | "Yiisoft\\Yii\\Http\\Event\\ApplicationStartup", 14 | "Yiisoft\\Yii\\Http\\Event\\BeforeRequest", 15 | "Yiisoft\\Cache\\Dependency\\Dependency", 16 | "Yiisoft\\ErrorHandler\\Event\\ApplicationError", 17 | "PHPUnit\\Framework\\TestCase", 18 | "opcache_invalidate" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/yii-debug", 3 | "type": "library", 4 | "description": "Yii Framework Debug Panel Extension", 5 | "keywords": [ 6 | "yii", 7 | "debug", 8 | "debugger" 9 | ], 10 | "license": "BSD-3-Clause", 11 | "support": { 12 | "issues": "https://github.com/yiisoft/yii-debug/issues?state=open", 13 | "source": "https://github.com/yiisoft/yii-debug", 14 | "forum": "https://www.yiiframework.com/forum/", 15 | "wiki": "https://www.yiiframework.com/wiki/", 16 | "irc": "ircs://irc.libera.chat:6697/yii", 17 | "chat": "https://t.me/yii3en" 18 | }, 19 | "funding": [ 20 | { 21 | "type": "opencollective", 22 | "url": "https://opencollective.com/yiisoft" 23 | }, 24 | { 25 | "type": "github", 26 | "url": "https://github.com/sponsors/yiisoft" 27 | } 28 | ], 29 | "require": { 30 | "php": "^8.1", 31 | "ext-mbstring": "*", 32 | "guzzlehttp/psr7": "^2.4", 33 | "psr/container": "^2.0", 34 | "psr/event-dispatcher": "^1.0", 35 | "psr/http-client": "^1.0", 36 | "psr/http-message": "^1.0|^2.0", 37 | "psr/log": "^1.0|^2.0|^3.0", 38 | "symfony/console": "^5.4|^6.0|^7.0", 39 | "yiisoft/di": "^1.0", 40 | "yiisoft/files": "^2.0", 41 | "yiisoft/profiler": "^3.0", 42 | "yiisoft/proxy": "^1.0.1", 43 | "yiisoft/strings": "^2.5", 44 | "yiisoft/var-dumper": "^1.7" 45 | }, 46 | "require-dev": { 47 | "ext-curl": "*", 48 | "ext-sockets": "*", 49 | "jetbrains/phpstorm-attributes": "^1.2", 50 | "maglnet/composer-require-checker": "^4.2", 51 | "nyholm/psr7": "^1.3", 52 | "phpunit/phpunit": "^10.5", 53 | "rector/rector": "^1.2", 54 | "roave/infection-static-analysis-plugin": "^1.16", 55 | "spatie/phpunit-watcher": "^1.23", 56 | "vimeo/psalm": "^5.26", 57 | "yiisoft/error-handler": "^3.0", 58 | "yiisoft/event-dispatcher": "^1.0", 59 | "yiisoft/log": "^2.0", 60 | "yiisoft/yii-console": "^2.0", 61 | "yiisoft/yii-http": "^1.0" 62 | }, 63 | "autoload": { 64 | "psr-4": { 65 | "Yiisoft\\Yii\\Debug\\": "src", 66 | "Yiisoft\\Yii\\Debug\\Tests\\Shared\\": "tests/Shared" 67 | } 68 | }, 69 | "autoload-dev": { 70 | "psr-4": { 71 | "Yiisoft\\Yii\\Debug\\Tests\\": "tests" 72 | } 73 | }, 74 | "extra": { 75 | "config-plugin-options": { 76 | "source-directory": "config" 77 | }, 78 | "config-plugin": { 79 | "params": "params.php", 80 | "bootstrap": "bootstrap.php", 81 | "di": "di.php", 82 | "di-console": "di-console.php", 83 | "di-web": "di-web.php", 84 | "di-providers": "di-providers.php", 85 | "events-web": "events-web.php", 86 | "events-console": "events-console.php" 87 | } 88 | }, 89 | "config": { 90 | "sort-packages": true, 91 | "allow-plugins": { 92 | "infection/extension-installer": true, 93 | "composer/package-versions-deprecated": true 94 | } 95 | }, 96 | "scripts": { 97 | "test": "phpunit --testdox --no-interaction", 98 | "test-watch": "phpunit-watcher watch" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | has(VarDumperCollector::class)) { 19 | return; 20 | } 21 | 22 | VarDumper::setDefaultHandler( 23 | new VarDumperHandlerInterfaceProxy( 24 | VarDumper::getDefaultHandler(), 25 | $container->get(VarDumperCollector::class), 26 | ), 27 | ); 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /config/di-console.php: -------------------------------------------------------------------------------- 1 | [ 18 | '__construct()' => [ 19 | 'collectors' => ReferencesArray::from( 20 | array_merge( 21 | $params['yiisoft/yii-debug']['collectors'], 22 | $params['yiisoft/yii-debug']['collectors.console'] ?? [] 23 | ) 24 | ), 25 | 'debuggerStartupPolicy' => DynamicReference::to( 26 | static fn () => new DenyDebuggerPolicy( 27 | new EnvironmentVariableCondition('YII_DEBUG_IGNORE'), 28 | new CommandNameCondition($params['yiisoft/yii-debug']['ignoredCommands']) 29 | ), 30 | ), 31 | 'excludedClasses' => $params['yiisoft/yii-debug']['excludedClasses'], 32 | ], 33 | 'reset' => function () { 34 | /** @var Debugger $this */ 35 | $this->allowStart = true; 36 | }, 37 | ], 38 | ]; 39 | -------------------------------------------------------------------------------- /config/di-providers.php: -------------------------------------------------------------------------------- 1 | DebugServiceProvider::class, 13 | ]; 14 | -------------------------------------------------------------------------------- /config/di-web.php: -------------------------------------------------------------------------------- 1 | [ 19 | '__construct()' => [ 20 | 'collectors' => ReferencesArray::from( 21 | array_merge( 22 | $params['yiisoft/yii-debug']['collectors'], 23 | $params['yiisoft/yii-debug']['collectors.web'] ?? [], 24 | ) 25 | ), 26 | 'debuggerStartupPolicy' => DynamicReference::to( 27 | static fn () => new DenyDebuggerPolicy( 28 | new EnvironmentVariableCondition('YII_DEBUG_IGNORE'), 29 | new HeaderCondition('X-Debug-Ignore'), 30 | new UriPathCondition($params['yiisoft/yii-debug']['ignoredRequests']) 31 | ), 32 | ), 33 | 'excludedClasses' => $params['yiisoft/yii-debug']['excludedClasses'], 34 | ], 35 | 'reset' => function () { 36 | /** @var Debugger $this */ 37 | $this->allowStart = true; 38 | }, 39 | ], 40 | ]; 41 | -------------------------------------------------------------------------------- /config/di.php: -------------------------------------------------------------------------------- 1 | static function (?Aliases $aliases = null) use ($params) { 24 | $params = $params['yiisoft/yii-debug']; 25 | 26 | $path = $params['path']; 27 | if (str_starts_with($path, '@')) { 28 | if ($aliases === null) { 29 | throw new LogicException( 30 | sprintf( 31 | 'yiisoft/aliases dependency is required to resolve path "%s".', 32 | $path 33 | ) 34 | ); 35 | } 36 | $path = $aliases->get($path); 37 | } 38 | $fileStorage = new FileStorage($path); 39 | 40 | if (isset($params['historySize'])) { 41 | $fileStorage->setHistorySize((int) $params['historySize']); 42 | } 43 | 44 | return $fileStorage; 45 | }, 46 | ]; 47 | 48 | if (!(bool) ($params['yiisoft/yii-debug']['enabled'] ?? false)) { 49 | return $common; 50 | } 51 | 52 | return array_merge([ 53 | ContainerProxyConfig::class => static function (ContainerInterface $container) use ($params) { 54 | $params = $params['yiisoft/yii-debug']; 55 | $collector = $container->get(ServiceCollector::class); 56 | $dispatcher = $container->get(EventDispatcherInterface::class); 57 | $debuggerEnabled = (bool) ($params['enabled'] ?? false); 58 | $trackedServices = (array) ($params['trackedServices'] ?? []); 59 | $path = $container->get(Aliases::class)->get('@runtime/cache/container-proxy'); 60 | $logLevel = $params['logLevel'] ?? ContainerInterfaceProxy::LOG_NOTHING; 61 | 62 | return new ContainerProxyConfig( 63 | $debuggerEnabled, 64 | $trackedServices, 65 | $dispatcher, 66 | $collector, 67 | $path, 68 | $logLevel 69 | ); 70 | }, 71 | FilesystemStreamCollector::class => [ 72 | '__construct()' => [ 73 | 'ignoredPathPatterns' => [ 74 | /** 75 | * Examples: 76 | * - templates/ 77 | * - src/Directory/To/Ignore 78 | */ 79 | ], 80 | 'ignoredClasses' => [ 81 | ClosureExporter::class, 82 | UseStatementParser::class, 83 | FileStorage::class, 84 | ClassLoader::class, 85 | ], 86 | ], 87 | ], 88 | ], $common); 89 | -------------------------------------------------------------------------------- /config/events-console.php: -------------------------------------------------------------------------------- 1 | [ 20 | [Debugger::class, 'start'], 21 | [ConsoleAppInfoCollector::class, 'collect'], 22 | ], 23 | ApplicationShutdown::class => [ 24 | [ConsoleAppInfoCollector::class, 'collect'], 25 | [Debugger::class, 'stop'], 26 | ], 27 | ConsoleCommandEvent::class => [ 28 | [ConsoleAppInfoCollector::class, 'collect'], 29 | [CommandCollector::class, 'collect'], 30 | ], 31 | ConsoleErrorEvent::class => [ 32 | [ConsoleAppInfoCollector::class, 'collect'], 33 | [CommandCollector::class, 'collect'], 34 | ], 35 | ConsoleTerminateEvent::class => [ 36 | [ConsoleAppInfoCollector::class, 'collect'], 37 | [CommandCollector::class, 'collect'], 38 | ], 39 | ]; 40 | -------------------------------------------------------------------------------- /config/events-web.php: -------------------------------------------------------------------------------- 1 | [ 23 | [Debugger::class, 'start'], 24 | [WebAppInfoCollector::class, 'collect'], 25 | ], 26 | ApplicationShutdown::class => [ 27 | [WebAppInfoCollector::class, 'collect'], 28 | ], 29 | BeforeRequest::class => [ 30 | [Debugger::class, 'start'], 31 | [WebAppInfoCollector::class, 'collect'], 32 | [RequestCollector::class, 'collect'], 33 | ], 34 | AfterRequest::class => [ 35 | [WebAppInfoCollector::class, 'collect'], 36 | [RequestCollector::class, 'collect'], 37 | ], 38 | AfterEmit::class => [ 39 | [ProfilerInterface::class, 'flush'], 40 | [WebAppInfoCollector::class, 'collect'], 41 | [Debugger::class, 'stop'], 42 | ], 43 | ApplicationError::class => [ 44 | [ExceptionCollector::class, 'collect'], 45 | ], 46 | ]; 47 | -------------------------------------------------------------------------------- /config/params.php: -------------------------------------------------------------------------------- 1 | [ 35 | 'enabled' => true, 36 | 'collectors' => [ 37 | LogCollector::class, 38 | EventCollector::class, 39 | ServiceCollector::class, 40 | HttpClientCollector::class, 41 | FilesystemStreamCollector::class, 42 | HttpStreamCollector::class, 43 | ExceptionCollector::class, 44 | VarDumperCollector::class, 45 | TimelineCollector::class, 46 | ], 47 | 'collectors.web' => [ 48 | WebAppInfoCollector::class, 49 | RequestCollector::class, 50 | ], 51 | 'collectors.console' => [ 52 | ConsoleAppInfoCollector::class, 53 | CommandCollector::class, 54 | ], 55 | 'trackedServices' => [ 56 | Injector::class => fn (ContainerInterface $container) => new Injector($container), 57 | LoggerInterface::class => [LoggerInterfaceProxy::class, LogCollector::class], 58 | EventDispatcherInterface::class => [EventDispatcherInterfaceProxy::class, EventCollector::class], 59 | ClientInterface::class => [HttpClientInterfaceProxy::class, HttpClientCollector::class], 60 | ], 61 | 'excludedClasses' => [ 62 | 'PhpParser\\Parser\\Php7', 63 | 'PhpParser\\NodeTraverser', 64 | 'PhpParser\\NodeVisitor\\NameResolver', 65 | 'PhpParser\\NameContext', 66 | 'PhpParser\\Node\\Name', 67 | 'PhpParser\\ErrorHandler\\Throwing', 68 | 'Spiral\\Attributes\\Internal\\AttributeParser', 69 | 'Doctrine\\Inflector\\Rules\\Pattern', 70 | 'Doctrine\\Inflector\\Rules\\Word', 71 | 'Doctrine\\Inflector\\Rules\\Substitution', 72 | 'Doctrine\\Inflector\\Rules\\Transformation', 73 | ], 74 | 'logLevel' => ContainerInterfaceProxy::LOG_ARGUMENTS | ContainerInterfaceProxy::LOG_RESULT | ContainerInterfaceProxy::LOG_ERROR, 75 | 'path' => '@runtime/debug', 76 | 'ignoredRequests' => [ 77 | // Paths to ignore the debugger, e.g.: 78 | //'/assets/*', 79 | ], 80 | 'ignoredCommands' => [ 81 | 'completion', 82 | 'help', 83 | 'list', 84 | 'serve', 85 | 'debug/reset', 86 | ], 87 | ], 88 | 'yiisoft/yii-console' => [ 89 | 'commands' => [ 90 | 'debug:reset' => DebugResetCommand::class, 91 | ], 92 | ], 93 | ]; 94 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "php:\/\/stderr", 9 | "stryker": { 10 | "report": "master" 11 | } 12 | }, 13 | "mutators": { 14 | "@default": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 11 | __DIR__ . '/src', 12 | __DIR__ . '/tests', 13 | ]); 14 | 15 | // register a single rule 16 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); 17 | 18 | // define sets of rules 19 | $rectorConfig->sets([ 20 | LevelSetList::UP_TO_PHP_81, 21 | ]); 22 | 23 | $rectorConfig->skip([ 24 | __DIR__ . '/tests/Unit/DataNormalizerTest.php', 25 | ]); 26 | }; 27 | -------------------------------------------------------------------------------- /src/Collector/CollectorInterface.php: -------------------------------------------------------------------------------- 1 | isActive = true; 14 | } 15 | 16 | public function shutdown(): void 17 | { 18 | $this->reset(); 19 | $this->isActive = false; 20 | } 21 | 22 | public function getName(): string 23 | { 24 | return self::class; 25 | } 26 | 27 | private function reset(): void 28 | { 29 | } 30 | 31 | private function isActive(): bool 32 | { 33 | return $this->isActive; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Collector/Console/CommandCollector.php: -------------------------------------------------------------------------------- 1 | 40 | */ 41 | private array $commands = []; 42 | 43 | public function __construct( 44 | private readonly TimelineCollector $timelineCollector 45 | ) { 46 | } 47 | 48 | public function getCollected(): array 49 | { 50 | return $this->commands; 51 | } 52 | 53 | public function collect(ConsoleEvent|ConsoleErrorEvent|ConsoleTerminateEvent $event): void 54 | { 55 | if (!$this->isActive()) { 56 | return; 57 | } 58 | 59 | $this->timelineCollector->collect($this, spl_object_id($event)); 60 | 61 | $command = $event->getCommand(); 62 | 63 | if ($event instanceof ConsoleErrorEvent) { 64 | $this->commands[$event::class] = [ 65 | 'name' => $event->getInput()->getFirstArgument() ?? '', 66 | 'command' => $command, 67 | 'input' => $this->castInputToString($event->getInput()), 68 | 'output' => $this->fetchOutput($event->getOutput()), 69 | 'error' => $event->getError()->getMessage(), 70 | 'exitCode' => $event->getExitCode(), 71 | ]; 72 | 73 | return; 74 | } 75 | 76 | if ($event instanceof ConsoleTerminateEvent) { 77 | $this->commands[$event::class] = [ 78 | 'name' => $command?->getName() ?? $event->getInput()->getFirstArgument() ?? '', 79 | 'command' => $command, 80 | 'input' => $this->castInputToString($event->getInput()), 81 | 'output' => $this->fetchOutput($event->getOutput()), 82 | 'exitCode' => $event->getExitCode(), 83 | ]; 84 | return; 85 | } 86 | 87 | $definition = $command?->getDefinition(); 88 | $this->commands[$event::class] = [ 89 | 'name' => $command?->getName() ?? $event->getInput()->getFirstArgument() ?? '', 90 | 'command' => $command, 91 | 'input' => $this->castInputToString($event->getInput()), 92 | 'output' => $this->fetchOutput($event->getOutput()), 93 | 'arguments' => $definition?->getArguments() ?? [], 94 | 'options' => $definition?->getOptions() ?? [], 95 | ]; 96 | } 97 | 98 | public function getSummary(): array 99 | { 100 | if (empty($this->commands)) { 101 | return []; 102 | } 103 | 104 | $eventTypes = $this->getSupportedEvents(); 105 | 106 | $commandEvent = null; 107 | foreach ($eventTypes as $eventType) { 108 | if (!array_key_exists($eventType, $this->commands)) { 109 | continue; 110 | } 111 | 112 | $commandEvent = $this->commands[$eventType]; 113 | break; 114 | } 115 | 116 | if ($commandEvent === null) { 117 | return []; 118 | } 119 | 120 | return [ 121 | 'command' => [ 122 | 'name' => $commandEvent['name'], 123 | 'class' => $commandEvent['command'] instanceof Command ? $commandEvent['command']::class : null, 124 | 'input' => $commandEvent['input'], 125 | 'exitCode' => $commandEvent['exitCode'] ?? self::UNDEFINED_EXIT_CODE, 126 | ], 127 | ]; 128 | } 129 | 130 | private function reset(): void 131 | { 132 | $this->commands = []; 133 | } 134 | 135 | private function fetchOutput(OutputInterface $output): ?string 136 | { 137 | return $output instanceof ConsoleBufferedOutput ? $output->fetch() : null; 138 | } 139 | 140 | private function castInputToString(InputInterface $input): ?string 141 | { 142 | return method_exists($input, '__toString') ? $input->__toString() : null; 143 | } 144 | 145 | /** 146 | * @return string[] 147 | */ 148 | private function getSupportedEvents(): array 149 | { 150 | return [ 151 | ConsoleErrorEvent::class, 152 | ConsoleTerminateEvent::class, 153 | ConsoleEvent::class, 154 | ]; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Collector/Console/ConsoleAppInfoCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 33 | return []; 34 | } 35 | return [ 36 | 'applicationProcessingTime' => $this->applicationProcessingTimeStopped - $this->applicationProcessingTimeStarted, 37 | 'preloadTime' => $this->applicationProcessingTimeStarted - $this->requestProcessingTimeStarted, 38 | 'applicationEmit' => $this->applicationProcessingTimeStopped - $this->requestProcessingTimeStopped, 39 | 'requestProcessingTime' => $this->requestProcessingTimeStopped - $this->requestProcessingTimeStarted, 40 | 'memoryPeakUsage' => memory_get_peak_usage(), 41 | 'memoryUsage' => memory_get_usage(), 42 | ]; 43 | } 44 | 45 | public function collect(object $event): void 46 | { 47 | if (!$this->isActive()) { 48 | return; 49 | } 50 | 51 | if ($event instanceof ApplicationStartup) { 52 | $this->applicationProcessingTimeStarted = microtime(true); 53 | } elseif ($event instanceof ConsoleCommandEvent) { 54 | $this->requestProcessingTimeStarted = microtime(true); 55 | } elseif ($event instanceof ConsoleErrorEvent) { 56 | /** 57 | * If we receive this event, then {@see ConsoleCommandEvent} hasn't received and won't. 58 | * So {@see requestProcessingTimeStarted} equals to 0 now and better to set it at least with application startup time. 59 | */ 60 | $this->requestProcessingTimeStarted = $this->applicationProcessingTimeStarted; 61 | $this->requestProcessingTimeStopped = microtime(true); 62 | } elseif ($event instanceof ConsoleTerminateEvent) { 63 | $this->requestProcessingTimeStopped = microtime(true); 64 | } elseif ($event instanceof ApplicationShutdown) { 65 | $this->applicationProcessingTimeStopped = microtime(true); 66 | } 67 | 68 | $this->timelineCollector->collect($this, spl_object_id($event)); 69 | } 70 | 71 | public function getSummary(): array 72 | { 73 | if (!$this->isActive()) { 74 | return []; 75 | } 76 | return [ 77 | 'console' => [ 78 | 'php' => [ 79 | 'version' => PHP_VERSION, 80 | ], 81 | 'request' => [ 82 | 'startTime' => $this->requestProcessingTimeStarted, 83 | 'processingTime' => $this->requestProcessingTimeStopped - $this->requestProcessingTimeStarted, 84 | ], 85 | 'memory' => [ 86 | 'peakUsage' => memory_get_peak_usage(), 87 | ], 88 | ], 89 | ]; 90 | } 91 | 92 | private function reset(): void 93 | { 94 | $this->applicationProcessingTimeStarted = 0; 95 | $this->applicationProcessingTimeStopped = 0; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Collector/ContainerInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | private array $serviceProxy = []; 38 | 39 | public function __construct( 40 | protected ContainerInterface $decorated, 41 | ContainerProxyConfig $config, 42 | ) { 43 | $this->config = $config; 44 | $this->proxyManager = new ProxyManager($this->config->getProxyCachePath()); 45 | } 46 | 47 | /** 48 | * @psalm-param array $decoratedServices 49 | */ 50 | public function withDecoratedServices(array $decoratedServices): self 51 | { 52 | $new = clone $this; 53 | $new->config = $this->config->withDecoratedServices($decoratedServices); 54 | return $new; 55 | } 56 | 57 | public function get($id): mixed 58 | { 59 | $this->resetCurrentError(); 60 | $timeStart = microtime(true); 61 | $instance = null; 62 | try { 63 | $instance = $this->getInstance($id); 64 | } catch (ContainerExceptionInterface $e) { 65 | $this->repeatError($e); 66 | } finally { 67 | $this->logProxy(ContainerInterface::class, $this->decorated, 'get', [$id], $instance, $timeStart); 68 | } 69 | 70 | if ( 71 | is_object($instance) 72 | && ( 73 | ($proxy = $this->getServiceProxyCache($id)) || 74 | ($proxy = $this->getServiceProxy($id, $instance)) 75 | ) 76 | ) { 77 | $this->setServiceProxyCache($id, $proxy); 78 | return $proxy; 79 | } 80 | 81 | return $instance; 82 | } 83 | 84 | /** 85 | * @throws ContainerExceptionInterface 86 | */ 87 | private function getInstance(string $id): mixed 88 | { 89 | if ($id === ContainerInterface::class) { 90 | return $this; 91 | } 92 | 93 | return $this->decorated->get($id); 94 | } 95 | 96 | private function isDecorated(string $service): bool 97 | { 98 | return $this->isActive() && $this->config->hasDecoratedService($service); 99 | } 100 | 101 | public function isActive(): bool 102 | { 103 | return $this->config->getIsActive() && $this->config->getDecoratedServices() !== []; 104 | } 105 | 106 | private function getServiceProxyCache(string $service): ?object 107 | { 108 | return $this->serviceProxy[$service] ?? null; 109 | } 110 | 111 | private function getServiceProxy(string $service, object $instance): ?object 112 | { 113 | if (!$this->isDecorated($service)) { 114 | return null; 115 | } 116 | 117 | if ($this->config->hasDecoratedServiceCallableConfig($service)) { 118 | /** @psalm-suppress MixedArgument */ 119 | return $this->getServiceProxyFromCallable($this->config->getDecoratedServiceConfig($service), $instance); 120 | } 121 | 122 | if ($this->config->hasDecoratedServiceArrayConfigWithStringKeys($service)) { 123 | /** @psalm-suppress MixedArgument */ 124 | return $this->getCommonMethodProxy( 125 | interface_exists($service) || class_exists($service) ? $service : $instance::class, 126 | $instance, 127 | $this->config->getDecoratedServiceConfig($service) 128 | ); 129 | } 130 | 131 | if ($this->config->hasDecoratedServiceArrayConfig($service)) { 132 | /** @psalm-suppress MixedArgument */ 133 | return $this->getServiceProxyFromArray($instance, $this->config->getDecoratedServiceConfig($service)); 134 | } 135 | 136 | if (interface_exists($service) && ($this->config->hasCollector() || $this->config->hasDispatcher())) { 137 | return $this->getCommonServiceProxy($service, $instance); 138 | } 139 | 140 | return null; 141 | } 142 | 143 | /** 144 | * @psalm-param callable(ContainerInterface, object):(object|null) $callback 145 | */ 146 | private function getServiceProxyFromCallable(callable $callback, object $instance): ?object 147 | { 148 | return $callback($this, $instance); 149 | } 150 | 151 | /** 152 | * @psalm-param class-string $service 153 | */ 154 | private function getCommonMethodProxy(string $service, object $instance, array $callbacks): ObjectProxy 155 | { 156 | $methods = []; 157 | foreach ($callbacks as $method => $callback) { 158 | if (is_string($method) && is_callable($callback)) { 159 | $methods[$method] = $callback; 160 | } 161 | } 162 | 163 | return $this->proxyManager->createObjectProxy( 164 | $service, 165 | ServiceMethodProxy::class, 166 | [$service, $instance, $methods, $this->config] 167 | ); 168 | } 169 | 170 | private function getServiceProxyFromArray(object $instance, array $params): ?object 171 | { 172 | try { 173 | $proxyClass = array_shift($params); 174 | foreach ($params as $index => $param) { 175 | if (is_string($param)) { 176 | try { 177 | $params[$index] = $this->get($param); 178 | } catch (Exception) { 179 | // leave as is 180 | } 181 | } 182 | } 183 | /** @psalm-suppress MixedMethodCall */ 184 | return new $proxyClass($instance, ...$params); 185 | } catch (Exception) { 186 | return null; 187 | } 188 | } 189 | 190 | /** 191 | * @psalm-param class-string $service 192 | */ 193 | private function getCommonServiceProxy(string $service, object $instance): object 194 | { 195 | return $this->proxyManager->createObjectProxy( 196 | $service, 197 | ServiceProxy::class, 198 | [$service, $instance, $this->config] 199 | ); 200 | } 201 | 202 | private function setServiceProxyCache(string $service, object $instance): void 203 | { 204 | $this->serviceProxy[$service] = $instance; 205 | } 206 | 207 | /** 208 | * @psalm-suppress InvalidCatch 209 | */ 210 | public function has($id): bool 211 | { 212 | $this->resetCurrentError(); 213 | $timeStart = microtime(true); 214 | $result = null; 215 | 216 | try { 217 | $result = $this->decorated->has($id); 218 | } catch (ContainerExceptionInterface $e) { 219 | $this->repeatError($e); 220 | } finally { 221 | $this->logProxy(ContainerInterface::class, $this->decorated, 'has', [$id], $result, $timeStart); 222 | } 223 | 224 | return (bool)$result; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Collector/ContainerProxyConfig.php: -------------------------------------------------------------------------------- 1 | $decoratedServices 17 | */ 18 | public function __construct( 19 | private bool $active = false, 20 | private array $decoratedServices = [], 21 | private ?EventDispatcherInterface $dispatcher = null, 22 | private ?ServiceCollector $collector = null, 23 | private ?string $proxyCachePath = null, 24 | private int $logLevel = ContainerInterfaceProxy::LOG_NOTHING, 25 | ) { 26 | } 27 | 28 | public function activate(): self 29 | { 30 | $config = clone $this; 31 | $config->active = true; 32 | 33 | return $config; 34 | } 35 | 36 | public function withDispatcher(EventDispatcherInterface $dispatcher): self 37 | { 38 | $config = clone $this; 39 | $config->dispatcher = $dispatcher; 40 | 41 | return $config; 42 | } 43 | 44 | public function withLogLevel(int $logLevel): self 45 | { 46 | $config = clone $this; 47 | $config->logLevel = $logLevel; 48 | 49 | return $config; 50 | } 51 | 52 | public function withProxyCachePath(string $proxyCachePath): self 53 | { 54 | $config = clone $this; 55 | $config->proxyCachePath = $proxyCachePath; 56 | 57 | return $config; 58 | } 59 | 60 | public function withCollector(ServiceCollector $collector): self 61 | { 62 | $config = clone $this; 63 | $config->collector = $collector; 64 | 65 | return $config; 66 | } 67 | 68 | /** 69 | * @psalm-param array $decoratedServices 70 | */ 71 | public function withDecoratedServices(array $decoratedServices): self 72 | { 73 | $config = clone $this; 74 | $config->decoratedServices = array_merge($this->decoratedServices, $decoratedServices); 75 | 76 | return $config; 77 | } 78 | 79 | public function getIsActive(): bool 80 | { 81 | return $this->active; 82 | } 83 | 84 | public function getLogLevel(): int 85 | { 86 | return $this->logLevel; 87 | } 88 | 89 | public function getDecoratedServices(): array 90 | { 91 | return $this->decoratedServices; 92 | } 93 | 94 | public function getDispatcher(): ?EventDispatcherInterface 95 | { 96 | return $this->dispatcher; 97 | } 98 | 99 | public function getCollector(): ?ServiceCollector 100 | { 101 | return $this->collector; 102 | } 103 | 104 | public function getProxyCachePath(): ?string 105 | { 106 | return $this->proxyCachePath; 107 | } 108 | 109 | public function getDecoratedServiceConfig(string $service): mixed 110 | { 111 | return $this->decoratedServices[$service]; 112 | } 113 | 114 | public function hasDecoratedService(string $service): bool 115 | { 116 | return isset($this->decoratedServices[$service]) || in_array($service, $this->decoratedServices, true); 117 | } 118 | 119 | public function hasDecoratedServiceArrayConfigWithStringKeys(string $service): bool 120 | { 121 | return $this->hasDecoratedServiceArrayConfig($service) && !isset($this->decoratedServices[$service][0]); 122 | } 123 | 124 | public function hasDecoratedServiceArrayConfig(string $service): bool 125 | { 126 | return isset($this->decoratedServices[$service]) && is_array($this->decoratedServices[$service]); 127 | } 128 | 129 | public function hasDecoratedServiceCallableConfig(string $service): bool 130 | { 131 | return isset($this->decoratedServices[$service]) && is_callable($this->decoratedServices[$service]); 132 | } 133 | 134 | public function hasDispatcher(): bool 135 | { 136 | return $this->dispatcher !== null; 137 | } 138 | 139 | public function hasCollector(): bool 140 | { 141 | return $this->collector !== null; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Collector/EventCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 25 | return []; 26 | } 27 | return $this->events; 28 | } 29 | 30 | public function collect(object $event, string $line): void 31 | { 32 | if ( 33 | !$event instanceof HttpApplicationStartup 34 | && !$event instanceof ConsoleApplicationStartup 35 | && !$this->isActive() 36 | ) { 37 | return; 38 | } 39 | 40 | $this->events[] = [ 41 | 'name' => $event::class, 42 | 'event' => $event, 43 | 'file' => (new ReflectionClass($event))->getFileName(), 44 | 'line' => $line, 45 | 'time' => microtime(true), 46 | ]; 47 | $this->timelineCollector->collect($this, spl_object_id($event), $event::class); 48 | } 49 | 50 | public function getSummary(): array 51 | { 52 | if (!$this->isActive()) { 53 | return []; 54 | } 55 | return [ 56 | 'event' => [ 57 | 'total' => count($this->events), 58 | ], 59 | ]; 60 | } 61 | 62 | private function reset(): void 63 | { 64 | $this->events = []; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Collector/EventDispatcherInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | collector->collect($event, $callStack['file'] . ':' . $callStack['line']); 26 | 27 | return $this->decorated->dispatch($event); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Collector/ExceptionCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 24 | return []; 25 | } 26 | if ($this->exception === null) { 27 | return []; 28 | } 29 | $throwable = $this->exception; 30 | $exceptions = [ 31 | $throwable, 32 | ]; 33 | while (($throwable = $throwable->getPrevious()) !== null) { 34 | $exceptions[] = $throwable; 35 | } 36 | 37 | return array_map([$this, 'serializeException'], $exceptions); 38 | } 39 | 40 | public function collect(ApplicationError $error): void 41 | { 42 | if (!$this->isActive()) { 43 | return; 44 | } 45 | 46 | $this->exception = $error->getThrowable(); 47 | $this->timelineCollector->collect($this, $error::class); 48 | } 49 | 50 | public function getSummary(): array 51 | { 52 | if (!$this->isActive()) { 53 | return []; 54 | } 55 | return [ 56 | 'exception' => $this->exception === null ? [] : [ 57 | 'class' => $this->exception::class, 58 | 'message' => $this->exception->getMessage(), 59 | 'file' => $this->exception->getFile(), 60 | 'line' => $this->exception->getLine(), 61 | 'code' => $this->exception->getCode(), 62 | ], 63 | ]; 64 | } 65 | 66 | private function reset(): void 67 | { 68 | $this->exception = null; 69 | } 70 | 71 | private function serializeException(Throwable $throwable): array 72 | { 73 | return [ 74 | 'class' => $throwable::class, 75 | 'message' => $throwable->getMessage(), 76 | 'file' => $throwable->getFile(), 77 | 'line' => $throwable->getLine(), 78 | 'code' => $throwable->getCode(), 79 | 'trace' => $throwable->getTrace(), 80 | 'traceAsString' => $throwable->getTraceAsString(), 81 | ]; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Collector/HttpClientCollector.php: -------------------------------------------------------------------------------- 1 | > 32 | */ 33 | private array $requests = []; 34 | 35 | public function __construct( 36 | private readonly TimelineCollector $timelineCollector 37 | ) { 38 | } 39 | 40 | public function collect(RequestInterface $request, float $startTime, string $line, string $uniqueId): void 41 | { 42 | if (!$this->isActive()) { 43 | return; 44 | } 45 | 46 | $this->requests[$uniqueId][] = [ 47 | 'startTime' => $startTime, 48 | 'endTime' => $startTime, 49 | 'totalTime' => 0.0, 50 | 'method' => $request->getMethod(), 51 | 'uri' => (string) $request->getUri(), 52 | 'headers' => $request->getHeaders(), 53 | 'line' => $line, 54 | ]; 55 | $this->timelineCollector->collect($this, $uniqueId); 56 | } 57 | 58 | public function collectTotalTime(?ResponseInterface $response, float $endTime, ?string $uniqueId): void 59 | { 60 | if (!$this->isActive()) { 61 | return; 62 | } 63 | 64 | if (!isset($this->requests[$uniqueId])) { 65 | return; 66 | } 67 | /** @psalm-suppress UnsupportedReferenceUsage */ 68 | $entry = &$this->requests[$uniqueId][count($this->requests[$uniqueId]) - 1]; 69 | if ($response instanceof ResponseInterface) { 70 | $entry['responseRaw'] = Message::toString($response); 71 | $entry['responseStatus'] = $response->getStatusCode(); 72 | Message::rewindBody($response); 73 | } 74 | $entry['endTime'] = $endTime; 75 | $entry['totalTime'] = $entry['endTime'] - $entry['startTime']; 76 | } 77 | 78 | public function getCollected(): array 79 | { 80 | if (!$this->isActive()) { 81 | return []; 82 | } 83 | return array_merge(...array_values($this->requests)); 84 | } 85 | 86 | public function getSummary(): array 87 | { 88 | if (!$this->isActive()) { 89 | return []; 90 | } 91 | return [ 92 | 'http' => [ 93 | 'count' => array_sum(array_map(static fn (array $requests) => count($requests), $this->requests)), 94 | 'totalTime' => array_sum( 95 | array_merge( 96 | ...array_map( 97 | static fn (array $entry) => array_column($entry, 'totalTime'), 98 | array_values($this->requests) 99 | ) 100 | ) 101 | ), 102 | ], 103 | ]; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Collector/HttpClientInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | collector->collect($request, $startTime, $callStack['file'] . ':' . $callStack['line'], $uniqueId); 30 | 31 | $response = null; 32 | try { 33 | $response = $this->decorated->sendRequest($request); 34 | } finally { 35 | $endTime = microtime(true); 36 | $this->collector->collectTotalTime($response, $endTime, $uniqueId); 37 | } 38 | 39 | return $response; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Collector/LogCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 21 | return []; 22 | } 23 | return $this->messages; 24 | } 25 | 26 | public function collect(string $level, mixed $message, array $context, string $line): void 27 | { 28 | if (!$this->isActive()) { 29 | return; 30 | } 31 | 32 | $this->messages[] = [ 33 | 'time' => microtime(true), 34 | 'level' => $level, 35 | 'message' => $message, 36 | 'context' => $context, 37 | 'line' => $line, 38 | ]; 39 | $this->timelineCollector->collect($this, count($this->messages)); 40 | } 41 | 42 | private function reset(): void 43 | { 44 | $this->messages = []; 45 | } 46 | 47 | public function getSummary(): array 48 | { 49 | if (!$this->isActive()) { 50 | return []; 51 | } 52 | return [ 53 | 'logger' => [ 54 | 'total' => count($this->messages), 55 | ], 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Collector/LoggerInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | getCallStack(); 25 | 26 | $this->collector->collect( 27 | LogLevel::EMERGENCY, 28 | $message, 29 | $context, 30 | $callStack['file'] . ':' . $callStack['line'] 31 | ); 32 | $this->decorated->emergency($message, $context); 33 | } 34 | 35 | public function alert(string|Stringable $message, array $context = []): void 36 | { 37 | $callStack = $this->getCallStack(); 38 | 39 | $this->collector->collect(LogLevel::ALERT, $message, $context, $callStack['file'] . ':' . $callStack['line']); 40 | $this->decorated->alert($message, $context); 41 | } 42 | 43 | public function critical(string|Stringable $message, array $context = []): void 44 | { 45 | $callStack = $this->getCallStack(); 46 | 47 | $this->collector->collect( 48 | LogLevel::CRITICAL, 49 | $message, 50 | $context, 51 | $callStack['file'] . ':' . $callStack['line'] 52 | ); 53 | $this->decorated->critical($message, $context); 54 | } 55 | 56 | public function error(string|Stringable $message, array $context = []): void 57 | { 58 | $callStack = $this->getCallStack(); 59 | 60 | $this->collector->collect(LogLevel::ERROR, $message, $context, $callStack['file'] . ':' . $callStack['line']); 61 | $this->decorated->error($message, $context); 62 | } 63 | 64 | public function warning(string|Stringable $message, array $context = []): void 65 | { 66 | $callStack = $this->getCallStack(); 67 | 68 | $this->collector->collect(LogLevel::WARNING, $message, $context, $callStack['file'] . ':' . $callStack['line']); 69 | $this->decorated->warning($message, $context); 70 | } 71 | 72 | public function notice(string|Stringable $message, array $context = []): void 73 | { 74 | $callStack = $this->getCallStack(); 75 | 76 | $this->collector->collect(LogLevel::NOTICE, $message, $context, $callStack['file'] . ':' . $callStack['line']); 77 | $this->decorated->notice($message, $context); 78 | } 79 | 80 | public function info(string|Stringable $message, array $context = []): void 81 | { 82 | $callStack = $this->getCallStack(); 83 | 84 | $this->collector->collect(LogLevel::INFO, $message, $context, $callStack['file'] . ':' . $callStack['line']); 85 | $this->decorated->info($message, $context); 86 | } 87 | 88 | public function debug(string|Stringable $message, array $context = []): void 89 | { 90 | $callStack = $this->getCallStack(); 91 | 92 | $this->collector->collect(LogLevel::DEBUG, $message, $context, $callStack['file'] . ':' . $callStack['line']); 93 | $this->decorated->debug($message, $context); 94 | } 95 | 96 | public function log(mixed $level, string|Stringable $message, array $context = []): void 97 | { 98 | $level = (string) $level; 99 | $callStack = $this->getCallStack(); 100 | 101 | $this->collector->collect($level, $message, $context, $callStack['file'] . ':' . $callStack['line']); 102 | $this->decorated->log($level, $message, $context); 103 | } 104 | 105 | /** 106 | * @psalm-return array{file: string, line: int} 107 | */ 108 | private function getCallStack(): array 109 | { 110 | /** @psalm-var array{file: string, line: int} */ 111 | return debug_backtrace()[1]; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Collector/ProxyLogTrait.php: -------------------------------------------------------------------------------- 1 | getCurrentError(); 22 | $this->processLogData($arguments, $result, $error); 23 | 24 | if ($this->config->getCollector() !== null) { 25 | $this->logToCollector($service, $instance, $method, $arguments, $result, $error, $timeStart); 26 | } 27 | 28 | if ($this->config->getDispatcher() !== null) { 29 | $this->logToEvent($service, $instance, $method, $arguments, $result, $error, $timeStart); 30 | } 31 | } 32 | 33 | /** 34 | * @psalm-param-out array|null $arguments 35 | */ 36 | private function processLogData(array &$arguments, mixed &$result, ?object &$error): void 37 | { 38 | if (!($this->config->getLogLevel() & ContainerInterfaceProxy::LOG_ARGUMENTS)) { 39 | $arguments = null; 40 | } 41 | 42 | if (!($this->config->getLogLevel() & ContainerInterfaceProxy::LOG_RESULT)) { 43 | $result = null; 44 | } 45 | 46 | if (!($this->config->getLogLevel() & ContainerInterfaceProxy::LOG_ERROR)) { 47 | $error = null; 48 | } 49 | } 50 | 51 | private function logToCollector( 52 | string $service, 53 | object $instance, 54 | string $method, 55 | ?array $arguments, 56 | mixed $result, 57 | ?object $error, 58 | float $timeStart 59 | ): void { 60 | $this->config->getCollector()?->collect( 61 | $service, 62 | $instance::class, 63 | $method, 64 | $arguments, 65 | $result, 66 | $this->getCurrentResultStatus(), 67 | $error, 68 | $timeStart, 69 | microtime(true), 70 | ); 71 | } 72 | 73 | private function logToEvent( 74 | string $service, 75 | object $instance, 76 | string $method, 77 | ?array $arguments, 78 | mixed $result, 79 | ?object $error, 80 | float $timeStart 81 | ): void { 82 | $this->config->getDispatcher()?->dispatch( 83 | new ProxyMethodCallEvent( 84 | $service, 85 | $instance::class, 86 | $method, 87 | $arguments, 88 | $result, 89 | $this->getCurrentResultStatus(), 90 | $error, 91 | $timeStart, 92 | microtime(true), 93 | ) 94 | ); 95 | } 96 | 97 | private function getCurrentResultStatus(): string 98 | { 99 | if (!$this->hasCurrentError()) { 100 | return 'success'; 101 | } 102 | 103 | return 'failed'; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Collector/ServiceCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 21 | return []; 22 | } 23 | return $this->items; 24 | } 25 | 26 | public function collect( 27 | string $service, 28 | string $class, 29 | string $method, 30 | ?array $arguments, 31 | mixed $result, 32 | string $status, 33 | ?object $error, 34 | float $timeStart, 35 | float $timeEnd 36 | ): void { 37 | if (!$this->isActive()) { 38 | return; 39 | } 40 | 41 | $this->items[] = [ 42 | 'service' => $service, 43 | 'class' => $class, 44 | 'method' => $method, 45 | 'arguments' => $arguments, 46 | 'result' => $result, 47 | 'status' => $status, 48 | 'error' => $error, 49 | 'timeStart' => $timeStart, 50 | 'timeEnd' => $timeEnd, 51 | ]; 52 | $this->timelineCollector->collect($this, count($this->items)); 53 | } 54 | 55 | public function getSummary(): array 56 | { 57 | if (!$this->isActive()) { 58 | return []; 59 | } 60 | return [ 61 | 'service' => [ 62 | 'total' => count($this->items), 63 | ], 64 | ]; 65 | } 66 | 67 | private function reset(): void 68 | { 69 | $this->items = []; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Collector/ServiceMethodProxy.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private readonly array $methods, 18 | ContainerProxyConfig $config 19 | ) { 20 | parent::__construct($service, $instance, $config); 21 | } 22 | 23 | protected function afterCall(string $methodName, array $arguments, mixed $result, float $timeStart): mixed 24 | { 25 | try { 26 | if (isset($this->methods[$methodName])) { 27 | $callback = $this->methods[$methodName]; 28 | $result = $callback($result, ...$arguments); 29 | } 30 | } finally { 31 | $this->logProxy($this->getService(), $this->getInstance(), $methodName, $arguments, $result, $timeStart); 32 | } 33 | 34 | return $result; 35 | } 36 | 37 | protected function getNewStaticInstance(object $instance): ObjectProxy 38 | { 39 | /** 40 | * @psalm-suppress UnsafeInstantiation Constructor should be consistent to `getNewStaticInstance()`. 41 | */ 42 | return new static($this->getService(), $instance, $this->methods, $this->getConfig()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Collector/ServiceProxy.php: -------------------------------------------------------------------------------- 1 | config = $config; 19 | parent::__construct($instance); 20 | } 21 | 22 | protected function afterCall(string $methodName, array $arguments, mixed $result, float $timeStart): mixed 23 | { 24 | $this->logProxy($this->service, $this->getInstance(), $methodName, $arguments, $result, $timeStart); 25 | return $result; 26 | } 27 | 28 | protected function getNewStaticInstance(object $instance): ObjectProxy 29 | { 30 | /** 31 | * @psalm-suppress UnsafeInstantiation Constructor should be consistent to `getNewStaticInstance()`. 32 | */ 33 | return new static($this->service, $instance, $this->config); 34 | } 35 | 36 | protected function getService(): string 37 | { 38 | return $this->service; 39 | } 40 | 41 | protected function getConfig(): ContainerProxyConfig 42 | { 43 | return $this->config; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Collector/Stream/FilesystemStreamCollector.php: -------------------------------------------------------------------------------- 1 | > 35 | */ 36 | private array $operations = []; 37 | 38 | public function getCollected(): array 39 | { 40 | return $this->isActive() ? $this->operations : []; 41 | } 42 | 43 | public function startup(): void 44 | { 45 | $this->isActive = true; 46 | FilesystemStreamProxy::register(); 47 | FilesystemStreamProxy::$collector = $this; 48 | FilesystemStreamProxy::$ignoredPathPatterns = $this->ignoredPathPatterns; 49 | FilesystemStreamProxy::$ignoredClasses = $this->ignoredClasses; 50 | } 51 | 52 | public function shutdown(): void 53 | { 54 | FilesystemStreamProxy::unregister(); 55 | FilesystemStreamProxy::$collector = null; 56 | FilesystemStreamProxy::$ignoredPathPatterns = []; 57 | FilesystemStreamProxy::$ignoredClasses = []; 58 | 59 | $this->reset(); 60 | $this->isActive = false; 61 | } 62 | 63 | public function collect(string $operation, string $path, array $args): void 64 | { 65 | if (!$this->isActive()) { 66 | return; 67 | } 68 | 69 | $this->operations[$operation][] = [ 70 | 'path' => $path, 71 | 'args' => $args, 72 | ]; 73 | } 74 | 75 | public function getSummary(): array 76 | { 77 | if (!$this->isActive()) { 78 | return []; 79 | } 80 | return [ 81 | 'fs_stream' => array_merge( 82 | ...array_map( 83 | fn (string $operation) => [$operation => count($this->operations[$operation])], 84 | array_keys($this->operations) 85 | ) 86 | ), 87 | ]; 88 | } 89 | 90 | private function reset(): void 91 | { 92 | $this->operations = []; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Collector/Stream/FilesystemStreamProxy.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | public array $operations = []; 44 | 45 | public function __construct() 46 | { 47 | $this->decorated = new StreamWrapper(); 48 | $this->decorated->context = $this->context; 49 | } 50 | 51 | public function __call(string $name, array $arguments) 52 | { 53 | try { 54 | self::unregister(); 55 | return $this->decorated->{$name}(...$arguments); 56 | } finally { 57 | self::register(); 58 | } 59 | } 60 | 61 | public function __destruct() 62 | { 63 | if (self::$collector === null) { 64 | return; 65 | } 66 | foreach ($this->operations as $name => $operation) { 67 | self::$collector->collect( 68 | operation: $name, 69 | path: $operation['path'], 70 | args: $operation['args'], 71 | ); 72 | } 73 | self::unregister(); 74 | } 75 | 76 | public function __get(string $name) 77 | { 78 | return $this->decorated->{$name}; 79 | } 80 | 81 | public static function register(): void 82 | { 83 | if (self::$registered) { 84 | return; 85 | } 86 | /** 87 | * It's important to trigger autoloader before unregistering the file stream handler 88 | */ 89 | class_exists(BacktraceMatcher::class); 90 | class_exists(StreamWrapper::class); 91 | class_exists(CombinedRegexp::class); 92 | class_exists(StringHelper::class); 93 | stream_wrapper_unregister('file'); 94 | stream_wrapper_register('file', self::class, STREAM_IS_URL); 95 | self::$registered = true; 96 | } 97 | 98 | public static function unregister(): void 99 | { 100 | if (!self::$registered) { 101 | return; 102 | } 103 | @stream_wrapper_restore('file'); 104 | self::$registered = false; 105 | } 106 | 107 | private function isIgnored(): bool 108 | { 109 | $backtrace = debug_backtrace(); 110 | return BacktraceMatcher::matchesClass($backtrace[3], self::$ignoredClasses) 111 | || BacktraceMatcher::matchesFile($backtrace[3], self::$ignoredPathPatterns); 112 | } 113 | 114 | public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool 115 | { 116 | $this->ignored = $this->isIgnored(); 117 | return $this->__call(__FUNCTION__, func_get_args()); 118 | } 119 | 120 | public function stream_read(int $count): string|false 121 | { 122 | if (!$this->ignored) { 123 | $this->operations['read'] = [ 124 | 'path' => $this->decorated->filename ?? '', 125 | 'args' => [], 126 | ]; 127 | } 128 | return $this->__call(__FUNCTION__, func_get_args()); 129 | } 130 | 131 | public function stream_set_option(int $option, int $arg1, ?int $arg2): bool 132 | { 133 | return $this->__call(__FUNCTION__, func_get_args()); 134 | } 135 | 136 | public function stream_tell(): int 137 | { 138 | return $this->__call(__FUNCTION__, func_get_args()); 139 | } 140 | 141 | public function stream_eof(): bool 142 | { 143 | return $this->__call(__FUNCTION__, func_get_args()); 144 | } 145 | 146 | public function stream_seek(int $offset, int $whence = SEEK_SET): bool 147 | { 148 | return $this->__call(__FUNCTION__, func_get_args()); 149 | } 150 | 151 | public function stream_cast(int $castAs) 152 | { 153 | return $this->__call(__FUNCTION__, func_get_args()); 154 | } 155 | 156 | public function stream_stat(): array|false 157 | { 158 | return $this->__call(__FUNCTION__, func_get_args()); 159 | } 160 | 161 | public function dir_closedir(): bool 162 | { 163 | return $this->__call(__FUNCTION__, func_get_args()); 164 | } 165 | 166 | public function dir_opendir(string $path, int $options): bool 167 | { 168 | return $this->__call(__FUNCTION__, func_get_args()); 169 | } 170 | 171 | public function dir_readdir(): false|string 172 | { 173 | if (!$this->ignored) { 174 | $this->operations['readdir'] = [ 175 | 'path' => $this->decorated->filename ?? '', 176 | 'args' => [], 177 | ]; 178 | } 179 | return $this->__call(__FUNCTION__, func_get_args()); 180 | } 181 | 182 | public function dir_rewinddir(): bool 183 | { 184 | return $this->__call(__FUNCTION__, func_get_args()); 185 | } 186 | 187 | public function mkdir(string $path, int $mode, int $options): bool 188 | { 189 | if (!$this->isIgnored()) { 190 | $this->operations['mkdir'] = [ 191 | 'path' => $path, 192 | 'args' => [ 193 | 'mode' => $mode, 194 | 'options' => $options, 195 | ], 196 | ]; 197 | } 198 | return $this->__call(__FUNCTION__, func_get_args()); 199 | } 200 | 201 | public function rename(string $path_from, string $path_to): bool 202 | { 203 | if (!$this->isIgnored()) { 204 | $this->operations['rename'] = [ 205 | 'path' => $path_from, 206 | 'args' => [ 207 | 'path_to' => $path_to, 208 | ], 209 | ]; 210 | } 211 | return $this->__call(__FUNCTION__, func_get_args()); 212 | } 213 | 214 | public function rmdir(string $path, int $options): bool 215 | { 216 | if (!$this->isIgnored()) { 217 | $this->operations['rmdir'] = [ 218 | 'path' => $path, 219 | 'args' => [ 220 | 'options' => $options, 221 | ], 222 | ]; 223 | } 224 | return $this->__call(__FUNCTION__, func_get_args()); 225 | } 226 | 227 | public function stream_close(): void 228 | { 229 | $this->__call(__FUNCTION__, func_get_args()); 230 | } 231 | 232 | public function stream_flush(): bool 233 | { 234 | return $this->__call(__FUNCTION__, func_get_args()); 235 | } 236 | 237 | public function stream_lock(int $operation): bool 238 | { 239 | return $this->__call(__FUNCTION__, func_get_args()); 240 | } 241 | 242 | public function stream_metadata(string $path, int $option, mixed $value): bool 243 | { 244 | return $this->__call(__FUNCTION__, func_get_args()); 245 | } 246 | 247 | public function stream_truncate(int $new_size): bool 248 | { 249 | return $this->__call(__FUNCTION__, func_get_args()); 250 | } 251 | 252 | public function stream_write(string $data): int 253 | { 254 | if (!$this->ignored) { 255 | $this->operations['write'] = [ 256 | 'path' => $this->decorated->filename ?? '', 257 | 'args' => [], 258 | ]; 259 | } 260 | 261 | return $this->__call(__FUNCTION__, func_get_args()); 262 | } 263 | 264 | public function unlink(string $path): bool 265 | { 266 | if (!$this->isIgnored()) { 267 | $this->operations['unlink'] = [ 268 | 'path' => $path, 269 | 'args' => [], 270 | ]; 271 | } 272 | return $this->__call(__FUNCTION__, func_get_args()); 273 | } 274 | 275 | public function url_stat(string $path, int $flags): array|false 276 | { 277 | return $this->__call(__FUNCTION__, func_get_args()); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/Collector/Stream/HttpStreamCollector.php: -------------------------------------------------------------------------------- 1 | > 34 | */ 35 | private array $requests = []; 36 | 37 | public function getCollected(): array 38 | { 39 | if (!$this->isActive()) { 40 | return []; 41 | } 42 | return $this->requests; 43 | } 44 | 45 | public function startup(): void 46 | { 47 | $this->isActive = true; 48 | HttpStreamProxy::register(); 49 | HttpStreamProxy::$ignoredPathPatterns = $this->ignoredPathPatterns; 50 | HttpStreamProxy::$ignoredClasses = $this->ignoredClasses; 51 | HttpStreamProxy::$ignoredUrls = $this->ignoredUrls; 52 | HttpStreamProxy::$collector = $this; 53 | 54 | // TODO: add cURL support, maybe through proxy? 55 | // https://github.com/php/php-src/issues/10509 56 | //stream_context_set_default([ 57 | // 'http' => [ 58 | // 'proxy' => 'yii-debug-http://127.0.0.1', 59 | // ], 60 | //]); 61 | } 62 | 63 | public function shutdown(): void 64 | { 65 | HttpStreamProxy::unregister(); 66 | HttpStreamProxy::$collector = null; 67 | 68 | $this->reset(); 69 | $this->isActive = false; 70 | } 71 | 72 | public function collect(string $operation, string $path, array $args): void 73 | { 74 | if (!$this->isActive()) { 75 | return; 76 | } 77 | 78 | $this->requests[$operation][] = [ 79 | 'uri' => $path, 80 | 'args' => $args, 81 | ]; 82 | } 83 | 84 | public function getSummary(): array 85 | { 86 | if (!$this->isActive()) { 87 | return []; 88 | } 89 | return [ 90 | 'http_stream' => array_merge( 91 | ...array_map( 92 | fn (string $operation) => [ 93 | $operation => count($this->requests[$operation]), 94 | ], 95 | array_keys($this->requests) 96 | ) 97 | ), 98 | ]; 99 | } 100 | 101 | private function reset(): void 102 | { 103 | $this->requests = []; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Collector/Stream/HttpStreamProxy.php: -------------------------------------------------------------------------------- 1 | 51 | */ 52 | public array $operations = []; 53 | 54 | public function __construct() 55 | { 56 | $this->decorated = new StreamWrapper(); 57 | $this->decorated->context = $this->context; 58 | } 59 | 60 | public function __call(string $name, array $arguments) 61 | { 62 | try { 63 | self::unregister(); 64 | return $this->decorated->{$name}(...$arguments); 65 | } finally { 66 | self::register(); 67 | } 68 | } 69 | 70 | public function __destruct() 71 | { 72 | if (self::$collector === null) { 73 | return; 74 | } 75 | foreach ($this->operations as $name => $operation) { 76 | self::$collector->collect( 77 | operation: $name, 78 | path: $operation['path'], 79 | args: $operation['args'], 80 | ); 81 | } 82 | self::unregister(); 83 | } 84 | 85 | public function __get(string $name) 86 | { 87 | return $this->decorated->{$name}; 88 | } 89 | 90 | public static function register(): void 91 | { 92 | if (self::$registered) { 93 | return; 94 | } 95 | /** 96 | * It's important to trigger autoloader before unregistering the file stream handler 97 | */ 98 | class_exists(BacktraceMatcher::class); 99 | class_exists(StreamWrapper::class); 100 | class_exists(CombinedRegexp::class); 101 | class_exists(StringHelper::class); 102 | stream_wrapper_unregister('http'); 103 | stream_wrapper_register('http', self::class, STREAM_IS_URL); 104 | 105 | if (in_array('https', stream_get_wrappers(), true)) { 106 | stream_wrapper_unregister('https'); 107 | stream_wrapper_register('https', self::class, STREAM_IS_URL); 108 | } 109 | 110 | self::$registered = true; 111 | } 112 | 113 | public static function unregister(): void 114 | { 115 | if (!self::$registered) { 116 | return; 117 | } 118 | @stream_wrapper_restore('http'); 119 | @stream_wrapper_restore('https'); 120 | self::$registered = false; 121 | } 122 | 123 | public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool 124 | { 125 | $this->ignored = $this->isIgnored($path); 126 | return $this->__call(__FUNCTION__, func_get_args()); 127 | } 128 | 129 | public function stream_read(int $count): string|false 130 | { 131 | if (!$this->ignored) { 132 | /** 133 | * @psalm-suppress PossiblyNullArgument 134 | */ 135 | $metadata = stream_get_meta_data($this->decorated->stream); 136 | $context = $this->decorated->context === null 137 | ? null 138 | : stream_context_get_options($this->decorated->context); 139 | /** 140 | * @link https://www.php.net/manual/en/context.http.php 141 | */ 142 | $method = $context['http']['method'] ?? $context['https']['method'] ?? 'GET'; 143 | $headers = (array) ($context['http']['header'] ?? $context['https']['header'] ?? []); 144 | 145 | $this->operations['read'] = [ 146 | 'path' => $this->decorated->filename ?? '', 147 | 'args' => [ 148 | 'method' => $method, 149 | 'response_headers' => $metadata['wrapper_data'], 150 | 'request_headers' => $headers, 151 | ], 152 | ]; 153 | } 154 | return $this->__call(__FUNCTION__, func_get_args()); 155 | } 156 | 157 | public function stream_set_option(int $option, int $arg1, ?int $arg2): bool 158 | { 159 | return $this->__call(__FUNCTION__, func_get_args()); 160 | } 161 | 162 | public function stream_tell(): int 163 | { 164 | return $this->__call(__FUNCTION__, func_get_args()); 165 | } 166 | 167 | public function stream_eof(): bool 168 | { 169 | return $this->__call(__FUNCTION__, func_get_args()); 170 | } 171 | 172 | public function stream_seek(int $offset, int $whence = SEEK_SET): bool 173 | { 174 | return $this->__call(__FUNCTION__, func_get_args()); 175 | } 176 | 177 | public function stream_cast(int $castAs) 178 | { 179 | return $this->__call(__FUNCTION__, func_get_args()); 180 | } 181 | 182 | public function stream_stat(): array|false 183 | { 184 | return $this->__call(__FUNCTION__, func_get_args()); 185 | } 186 | 187 | public function dir_closedir(): bool 188 | { 189 | return $this->__call(__FUNCTION__, func_get_args()); 190 | } 191 | 192 | public function dir_opendir(string $path, int $options): bool 193 | { 194 | return $this->__call(__FUNCTION__, func_get_args()); 195 | } 196 | 197 | public function dir_readdir(): false|string 198 | { 199 | if (!$this->ignored) { 200 | $this->operations[__FUNCTION__] = [ 201 | 'path' => $this->decorated->filename ?? '', 202 | 'args' => [], 203 | ]; 204 | } 205 | return $this->__call(__FUNCTION__, func_get_args()); 206 | } 207 | 208 | public function dir_rewinddir(): bool 209 | { 210 | return $this->__call(__FUNCTION__, func_get_args()); 211 | } 212 | 213 | public function mkdir(string $path, int $mode, int $options): bool 214 | { 215 | if (!$this->ignored) { 216 | $this->operations[__FUNCTION__] = [ 217 | 'path' => $path, 218 | 'args' => [ 219 | 'mode' => $mode, 220 | 'options' => $options, 221 | ], 222 | ]; 223 | } 224 | return $this->__call(__FUNCTION__, func_get_args()); 225 | } 226 | 227 | public function rename(string $path_from, string $path_to): bool 228 | { 229 | if (!$this->ignored) { 230 | $this->operations[__FUNCTION__] = [ 231 | 'path' => $path_from, 232 | 'args' => [ 233 | 'path_to' => $path_to, 234 | ], 235 | ]; 236 | } 237 | return $this->__call(__FUNCTION__, func_get_args()); 238 | } 239 | 240 | public function rmdir(string $path, int $options): bool 241 | { 242 | if (!$this->ignored) { 243 | $this->operations[__FUNCTION__] = [ 244 | 'path' => $path, 245 | 'args' => [ 246 | 'options' => $options, 247 | ], 248 | ]; 249 | } 250 | return $this->__call(__FUNCTION__, func_get_args()); 251 | } 252 | 253 | public function stream_close(): void 254 | { 255 | $this->__call(__FUNCTION__, func_get_args()); 256 | } 257 | 258 | public function stream_flush(): bool 259 | { 260 | return $this->__call(__FUNCTION__, func_get_args()); 261 | } 262 | 263 | public function stream_lock(int $operation): bool 264 | { 265 | return $this->__call(__FUNCTION__, func_get_args()); 266 | } 267 | 268 | public function stream_metadata(string $path, int $option, mixed $value): bool 269 | { 270 | return $this->__call(__FUNCTION__, func_get_args()); 271 | } 272 | 273 | public function stream_truncate(int $new_size): bool 274 | { 275 | return $this->__call(__FUNCTION__, func_get_args()); 276 | } 277 | 278 | public function stream_write(string $data): int 279 | { 280 | if (!$this->ignored) { 281 | $this->operations['write'] = [ 282 | 'path' => $this->decorated->filename ?? '', 283 | 'args' => [], 284 | ]; 285 | } 286 | 287 | return $this->__call(__FUNCTION__, func_get_args()); 288 | } 289 | 290 | public function unlink(string $path): bool 291 | { 292 | if (!$this->ignored) { 293 | $this->operations[__FUNCTION__] = [ 294 | 'path' => $path, 295 | 'args' => [], 296 | ]; 297 | } 298 | return $this->__call(__FUNCTION__, func_get_args()); 299 | } 300 | 301 | public function url_stat(string $path, int $flags): array|false 302 | { 303 | return $this->__call(__FUNCTION__, func_get_args()); 304 | } 305 | 306 | private function isIgnored(string $url): bool 307 | { 308 | if (StringHelper::matchAnyRegex($url, self::$ignoredUrls)) { 309 | return true; 310 | } 311 | 312 | $backtrace = debug_backtrace(); 313 | return BacktraceMatcher::matchesClass($backtrace[3], self::$ignoredClasses) 314 | || BacktraceMatcher::matchesFile($backtrace[3], self::$ignoredPathPatterns); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/Collector/SummaryCollectorInterface.php: -------------------------------------------------------------------------------- 1 | isActive()) { 16 | return []; 17 | } 18 | return $this->events; 19 | } 20 | 21 | public function collect(CollectorInterface $collector, string|int $reference, mixed ...$data): void 22 | { 23 | if (!$this->isActive()) { 24 | return; 25 | } 26 | 27 | $this->events[] = [microtime(true), $reference, $collector::class, array_values($data)]; 28 | } 29 | 30 | private function reset(): void 31 | { 32 | $this->events = []; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Collector/VarDumperCollector.php: -------------------------------------------------------------------------------- 1 | vars[] = [ 21 | 'variable' => $variable, 22 | 'line' => $line, 23 | ]; 24 | $this->timelineCollector->collect($this, count($this->vars)); 25 | } 26 | 27 | public function getCollected(): array 28 | { 29 | if (!$this->isActive()) { 30 | return []; 31 | } 32 | 33 | return $this->vars; 34 | } 35 | 36 | public function getSummary(): array 37 | { 38 | if (!$this->isActive()) { 39 | return []; 40 | } 41 | 42 | return [ 43 | 'var-dumper' => [ 44 | 'total' => count($this->vars), 45 | ], 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Collector/VarDumperHandlerInterfaceProxy.php: -------------------------------------------------------------------------------- 1 | collector->collect( 41 | $variable, 42 | $callStack === null ? '' : $callStack['file'] . ':' . $callStack['line'] 43 | ); 44 | $this->decorated->handle($variable, $depth, $highlight); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Collector/Web/RequestCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 38 | return []; 39 | } 40 | 41 | $requestRaw = null; 42 | if ($this->request instanceof ServerRequestInterface) { 43 | $requestRaw = Message::toString($this->request); 44 | Message::rewindBody($this->request); 45 | } 46 | 47 | $responseRaw = null; 48 | if ($this->response instanceof ResponseInterface) { 49 | $responseRaw = Message::toString($this->response); 50 | Message::rewindBody($this->response); 51 | } 52 | 53 | return [ 54 | 'requestUrl' => $this->requestUrl, 55 | 'requestPath' => $this->requestPath, 56 | 'requestQuery' => $this->requestQuery, 57 | 'requestMethod' => $this->requestMethod, 58 | 'requestIsAjax' => $this->requestIsAjax, 59 | 'userIp' => $this->userIp, 60 | 'responseStatusCode' => $this->responseStatusCode, 61 | 'request' => $this->request, 62 | 'requestRaw' => $requestRaw, 63 | 'response' => $this->response, 64 | 'responseRaw' => $responseRaw, 65 | ]; 66 | } 67 | 68 | public function collect(object $event): void 69 | { 70 | if (!$this->isActive()) { 71 | return; 72 | } 73 | 74 | if ($event instanceof BeforeRequest) { 75 | $request = $event->getRequest(); 76 | 77 | $this->request = $request; 78 | $this->requestUrl = (string) $request->getUri(); 79 | $this->requestPath = $request->getUri()->getPath(); 80 | $this->requestQuery = $request->getUri()->getQuery(); 81 | $this->requestMethod = $request->getMethod(); 82 | $this->requestIsAjax = strtolower($request->getHeaderLine('X-Requested-With')) === 'xmlhttprequest'; 83 | $this->userIp = $request->getServerParams()['REMOTE_ADDR'] ?? null; 84 | } 85 | if ($event instanceof AfterRequest) { 86 | $response = $event->getResponse(); 87 | 88 | $this->response = $response; 89 | $this->responseStatusCode = $response !== null ? $response->getStatusCode() : 500; 90 | } 91 | $this->timelineCollector->collect($this, spl_object_id($event)); 92 | } 93 | 94 | public function getSummary(): array 95 | { 96 | if (!$this->isActive()) { 97 | return []; 98 | } 99 | return [ 100 | 'request' => [ 101 | 'url' => $this->requestUrl, 102 | 'path' => $this->requestPath, 103 | 'query' => $this->requestQuery, 104 | 'method' => $this->requestMethod, 105 | 'isAjax' => $this->requestIsAjax, 106 | 'userIp' => $this->userIp, 107 | ], 108 | 'response' => [ 109 | 'statusCode' => $this->responseStatusCode, 110 | ], 111 | ]; 112 | } 113 | 114 | private function reset(): void 115 | { 116 | $this->request = null; 117 | $this->response = null; 118 | $this->requestUrl = ''; 119 | $this->requestMethod = ''; 120 | $this->requestIsAjax = false; 121 | $this->userIp = null; 122 | $this->responseStatusCode = 200; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Collector/Web/WebAppInfoCollector.php: -------------------------------------------------------------------------------- 1 | isActive()) { 32 | return []; 33 | } 34 | return [ 35 | 'applicationProcessingTime' => $this->applicationProcessingTimeStopped - $this->applicationProcessingTimeStarted, 36 | 'requestProcessingTime' => $this->requestProcessingTimeStopped - $this->requestProcessingTimeStarted, 37 | 'applicationEmit' => $this->applicationProcessingTimeStopped - $this->requestProcessingTimeStopped, 38 | 'preloadTime' => $this->requestProcessingTimeStarted - $this->applicationProcessingTimeStarted, 39 | 'memoryPeakUsage' => memory_get_peak_usage(), 40 | 'memoryUsage' => memory_get_usage(), 41 | ]; 42 | } 43 | 44 | public function collect(object $event): void 45 | { 46 | if (!$this->isActive()) { 47 | return; 48 | } 49 | 50 | if ($event instanceof ApplicationStartup) { 51 | $this->applicationProcessingTimeStarted = microtime(true); 52 | } elseif ($event instanceof BeforeRequest) { 53 | $this->requestProcessingTimeStarted = microtime(true); 54 | } elseif ($event instanceof AfterRequest) { 55 | $this->requestProcessingTimeStopped = microtime(true); 56 | } elseif ($event instanceof AfterEmit) { 57 | $this->applicationProcessingTimeStopped = microtime(true); 58 | } 59 | $this->timelineCollector->collect($this, spl_object_id($event)); 60 | } 61 | 62 | public function getSummary(): array 63 | { 64 | if (!$this->isActive()) { 65 | return []; 66 | } 67 | return [ 68 | 'web' => [ 69 | 'php' => [ 70 | 'version' => PHP_VERSION, 71 | ], 72 | 'request' => [ 73 | 'startTime' => $this->requestProcessingTimeStarted, 74 | 'processingTime' => $this->requestProcessingTimeStopped - $this->requestProcessingTimeStarted, 75 | ], 76 | 'memory' => [ 77 | 'peakUsage' => memory_get_peak_usage(), 78 | ], 79 | ], 80 | ]; 81 | } 82 | 83 | private function reset(): void 84 | { 85 | $this->applicationProcessingTimeStarted = 0; 86 | $this->applicationProcessingTimeStopped = 0; 87 | $this->requestProcessingTimeStarted = 0; 88 | $this->requestProcessingTimeStopped = 0; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Command/DebugResetCommand.php: -------------------------------------------------------------------------------- 1 | setHelp('This command clears debug storage data'); 32 | } 33 | 34 | protected function execute(InputInterface $input, OutputInterface $output): int 35 | { 36 | $this->debugger->kill(); 37 | $this->storage->clear(); 38 | 39 | return ExitCode::OK; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/DataNormalizer.php: -------------------------------------------------------------------------------- 1 | excludedClasses = array_flip($excludedClasses); 36 | } 37 | 38 | /** 39 | * @psalm-param positive-int|null $depth 40 | * @psalm-return list{mixed, array} 41 | */ 42 | public function prepareDataAndObjectsMap(array $value, ?int $depth = null): array 43 | { 44 | $objectsData = $this->makeObjectsData($value); 45 | 46 | $objectsMap = array_map( 47 | fn (object $object): mixed => $this->normalize( 48 | $object, 49 | $depth === null ? null : ($depth + 1), 50 | $objectsData, 51 | ), 52 | $objectsData, 53 | ); 54 | 55 | $data = $this->normalize($value, $depth, $objectsData); 56 | 57 | return [$data, $objectsMap]; 58 | } 59 | 60 | /** 61 | * @psalm-param positive-int|null $depth 62 | */ 63 | public function prepareData(array $value, ?int $depth = null): mixed 64 | { 65 | return $this->normalize($value, $depth); 66 | } 67 | 68 | /** 69 | * @psalm-param positive-int|null $depth 70 | * @psalm-param array $objectsData 71 | */ 72 | private function normalize(mixed $value, ?int $depth, array $objectsData = [], int $level = 0): mixed 73 | { 74 | return match (gettype($value)) { 75 | 'array' => $this->normalizeArray($value, $depth, $objectsData, $level), 76 | 'object' => $this->normalizeObject($value, $depth, $objectsData, $level), 77 | 'resource', 'resource (closed)' => $this->normalizeResource($value), 78 | default => $value, 79 | }; 80 | } 81 | 82 | /** 83 | * @psalm-param positive-int|null $depth 84 | * @psalm-param array $objectsData 85 | */ 86 | private function normalizeArray(array $array, ?int $depth, array $objectsData, int $level): string|array 87 | { 88 | if ($depth !== null && $depth <= $level) { 89 | $valuesCount = count($array); 90 | if ($valuesCount === 0) { 91 | return []; 92 | } 93 | return sprintf('array (%d %s) [...]', $valuesCount, $valuesCount === 1 ? 'item' : 'items'); 94 | } 95 | 96 | $result = []; 97 | foreach ($array as $key => $value) { 98 | $keyDisplay = str_replace("\0", '::', trim((string) $key)); 99 | $result[$keyDisplay] = $this->normalize($value, $depth, $objectsData, $level + 1); 100 | } 101 | return $result; 102 | } 103 | 104 | /** 105 | * @param positive-int|null $depth 106 | * @psalm-param array $objectsData 107 | */ 108 | private function normalizeObject(object $object, ?int $depth, array $objectsData, int $level): string|array 109 | { 110 | if ($object instanceof Closure) { 111 | return $this->normalizeClosure($object); 112 | } 113 | 114 | $objectId = $this->makeObjectId($object); 115 | 116 | if ($level > 0 && array_key_exists($objectId, $objectsData)) { 117 | return 'object@' . $objectId; 118 | } 119 | 120 | if ( 121 | ($depth !== null && $depth <= $level) 122 | || array_key_exists($object::class, $this->excludedClasses) 123 | || !array_key_exists($objectId, $objectsData) 124 | ) { 125 | return $objectId . ' (...)'; 126 | } 127 | 128 | $properties = $this->getObjectProperties($object); 129 | if (empty($properties)) { 130 | return '{stateless object}'; 131 | } 132 | 133 | $result = []; 134 | foreach ($properties as $key => $value) { 135 | $keyDisplay = $this->normalizeProperty((string) $key); 136 | $result[$keyDisplay] = $this->normalize($value, $depth, $objectsData, $level + 1); 137 | } 138 | return $result; 139 | } 140 | 141 | private function normalizeClosure(Closure $closure): string 142 | { 143 | return (self::$closureExporter ??= new ClosureExporter())->export($closure); 144 | } 145 | 146 | private function normalizeResource(mixed $resource): array|string 147 | { 148 | if (!is_resource($resource)) { 149 | return '{closed resource}'; 150 | } 151 | 152 | $type = get_resource_type($resource); 153 | if ($type === 'stream') { 154 | return stream_get_meta_data($resource); 155 | } 156 | if (!empty($type)) { 157 | return sprintf('{%s resource}', $type); 158 | } 159 | 160 | return '{resource}'; 161 | } 162 | 163 | /** 164 | * @psalm-return array 165 | */ 166 | private function makeObjectsData(mixed $value): array 167 | { 168 | $objectsData = []; 169 | $this->internalMakeObjectsData($value, $objectsData); 170 | return $objectsData; 171 | } 172 | 173 | /** 174 | * @psalm-param array $objectsData 175 | */ 176 | private function internalMakeObjectsData(mixed $value, array &$objectsData): void 177 | { 178 | if (is_object($value)) { 179 | if ($value instanceof Closure || array_key_exists($value::class, $this->excludedClasses)) { 180 | return; 181 | } 182 | $objectId = $this->makeObjectId($value); 183 | if (array_key_exists($objectId, $objectsData)) { 184 | return; 185 | } 186 | $objectsData[$objectId] = $value; 187 | } 188 | 189 | if (is_object($value)) { 190 | foreach ($this->getObjectProperties($value) as $propertyValue) { 191 | $this->internalMakeObjectsData($propertyValue, $objectsData); 192 | } 193 | return; 194 | } 195 | 196 | if (is_array($value)) { 197 | foreach ($value as $arrayItem) { 198 | $this->internalMakeObjectsData($arrayItem, $objectsData); 199 | } 200 | } 201 | } 202 | 203 | private function makeObjectId(object $object): string 204 | { 205 | if (str_contains($object::class, '@anonymous')) { 206 | return 'class@anonymous#' . spl_object_id($object); 207 | } 208 | return $object::class . '#' . spl_object_id($object); 209 | } 210 | 211 | private function getObjectProperties(object $object): array 212 | { 213 | if (__PHP_Incomplete_Class::class !== $object::class && method_exists($object, '__debugInfo')) { 214 | $object = $object->__debugInfo(); 215 | } 216 | return (array) $object; 217 | } 218 | 219 | private function normalizeProperty(string $property): string 220 | { 221 | $property = str_replace("\0", '::', trim($property)); 222 | 223 | if (str_starts_with($property, '*::')) { 224 | return 'protected $' . substr($property, 3); 225 | } 226 | 227 | if (($pos = strpos($property, '::')) !== false) { 228 | return 'private $' . substr($property, $pos + 2); 229 | } 230 | 231 | return 'public $' . $property; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/DebugServiceProvider.php: -------------------------------------------------------------------------------- 1 | 18 | static fn (ContainerInterface $container, ContainerProxyConfig $config) => new ContainerInterfaceProxy( 19 | $container, 20 | $config, 21 | ), 22 | ]; 23 | } 24 | 25 | public function getExtensions(): array 26 | { 27 | return []; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Debugger.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private readonly array $collectors; 27 | 28 | /** 29 | * @var DataNormalizer Data normalizer that prepares data for storage. 30 | */ 31 | private readonly DataNormalizer $dataNormalizer; 32 | 33 | /** 34 | * @var string|null ID of the current request. `null` if debugger is not active. 35 | */ 36 | private ?string $id = null; 37 | 38 | /** 39 | * @var bool Whether debugger startup is allowed. 40 | */ 41 | private bool $allowStart = true; 42 | 43 | /** 44 | * @param StorageInterface $storage The storage to store collected data. 45 | * @param CollectorInterface[] $collectors Collectors to be used. 46 | * @param DebuggerStartupPolicyInterface $debuggerStartupPolicy Policy to decide whether debugger should be started. 47 | * Default {@see AlwaysOnDebuggerPolicy} that always allows to startup debugger. 48 | * @param CollectorStartupPolicyInterface $collectorStartupPolicy Policy to decide whether collector should be 49 | * started. Default {@see AllowAllCollectorPolicy} that always allows to use all collectors. 50 | * @param array $excludedClasses List of classes to be excluded from collected data before storing. 51 | */ 52 | public function __construct( 53 | private readonly StorageInterface $storage, 54 | array $collectors, 55 | private readonly DebuggerStartupPolicyInterface $debuggerStartupPolicy = new AlwaysOnDebuggerPolicy(), 56 | private readonly CollectorStartupPolicyInterface $collectorStartupPolicy = new AllowAllCollectorPolicy(), 57 | array $excludedClasses = [], 58 | ) { 59 | $preparedCollectors = []; 60 | foreach ($collectors as $collector) { 61 | $preparedCollectors[$collector->getName()] = $collector; 62 | } 63 | $this->collectors = $preparedCollectors; 64 | 65 | $this->dataNormalizer = new DataNormalizer($excludedClasses); 66 | 67 | register_shutdown_function([$this, 'stop']); 68 | } 69 | 70 | /** 71 | * Returns whether debugger is active. 72 | * 73 | * @return bool Whether debugger is active. 74 | */ 75 | public function isActive(): bool 76 | { 77 | return $this->id !== null; 78 | } 79 | 80 | /** 81 | * Returns ID of the current request. 82 | * 83 | * Throws `LogicException` if debugger is not started. Use {@see isActive()} to check if debugger is active. 84 | * 85 | * @return string ID of the current request. 86 | */ 87 | public function getId(): string 88 | { 89 | return $this->id ?? throw new LogicException('Debugger is not started.'); 90 | } 91 | 92 | /** 93 | * Starts debugger and collectors. 94 | * 95 | * @param object $event Event that triggered debugger startup. 96 | */ 97 | public function start(object $event): void 98 | { 99 | if (!$this->allowStart) { 100 | return; 101 | } 102 | 103 | if (!$this->debuggerStartupPolicy->satisfies($event)) { 104 | $this->allowStart = false; 105 | $this->kill(); 106 | return; 107 | } 108 | 109 | if ($this->isActive()) { 110 | return; 111 | } 112 | 113 | $this->id = str_replace('.', '', uniqid('', true)); 114 | 115 | foreach ($this->collectors as $collector) { 116 | if ($this->collectorStartupPolicy->satisfies($collector, $event)) { 117 | $collector->startup(); 118 | } 119 | } 120 | } 121 | 122 | /** 123 | * Stops the debugger for listening. Collected data will be flushed to storage. 124 | */ 125 | public function stop(): void 126 | { 127 | if (!$this->isActive()) { 128 | return; 129 | } 130 | 131 | try { 132 | $this->flush(); 133 | } finally { 134 | $this->deactivate(); 135 | } 136 | } 137 | 138 | /** 139 | * Stops the debugger from listening. Collected data will not be flushed to storage. 140 | */ 141 | public function kill(): void 142 | { 143 | if (!$this->isActive()) { 144 | return; 145 | } 146 | 147 | $this->deactivate(); 148 | } 149 | 150 | /** 151 | * Collects data from collectors and stores it in a storage. 152 | */ 153 | private function flush(): void 154 | { 155 | $collectedData = array_map( 156 | static fn (CollectorInterface $collector) => $collector->getCollected(), 157 | $this->collectors 158 | ); 159 | 160 | /** @var array[] $data */ 161 | [$data, $objectsMap] = $this->dataNormalizer->prepareDataAndObjectsMap($collectedData, 30); 162 | 163 | /** @var array $summary */ 164 | $summary = $this->dataNormalizer->prepareData($this->collectSummaryData(), 30); 165 | 166 | $this->storage->write($this->getId(), $data, $objectsMap, $summary); 167 | } 168 | 169 | /** 170 | * Stops debugger and collectors. 171 | */ 172 | private function deactivate(): void 173 | { 174 | foreach ($this->collectors as $collector) { 175 | $collector->shutdown(); 176 | } 177 | $this->id = null; 178 | } 179 | 180 | /** 181 | * Collects summary data of current request. 182 | */ 183 | private function collectSummaryData(): array 184 | { 185 | $summaryData = [ 186 | 'id' => $this->getId(), 187 | 'collectors' => array_keys($this->collectors), 188 | ]; 189 | 190 | foreach ($this->collectors as $collector) { 191 | if ($collector instanceof SummaryCollectorInterface) { 192 | $summaryData = [...$summaryData, ...$collector->getSummary()]; 193 | } 194 | } 195 | 196 | return $summaryData; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Event/ProxyMethodCallEvent.php: -------------------------------------------------------------------------------- 1 | stream); 38 | 39 | /** 40 | * @psalm-suppress RedundantCondition 41 | */ 42 | return is_resource($this->stream); 43 | } 44 | 45 | public function dir_opendir(string $path, int $options): bool 46 | { 47 | $this->filename = $path; 48 | 49 | /** 50 | * @psalm-suppress PossiblyNullArgument 51 | */ 52 | $this->stream = opendir($path, $this->context); 53 | return is_resource($this->stream); 54 | } 55 | 56 | public function dir_readdir(): false|string 57 | { 58 | /** 59 | * @psalm-suppress PossiblyNullArgument 60 | */ 61 | return readdir($this->stream); 62 | } 63 | 64 | public function dir_rewinddir(): bool 65 | { 66 | if (!is_resource($this->stream)) { 67 | return false; 68 | } 69 | 70 | rewinddir($this->stream); 71 | 72 | /** 73 | * @noinspection PhpConditionAlreadyCheckedInspection 74 | * @psalm-suppress RedundantCondition 75 | */ 76 | return is_resource($this->stream); 77 | } 78 | 79 | public function mkdir(string $path, int $mode, int $options): bool 80 | { 81 | $this->filename = $path; 82 | 83 | /** 84 | * @psalm-suppress PossiblyNullArgument 85 | */ 86 | return mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) === STREAM_MKDIR_RECURSIVE, $this->context); 87 | } 88 | 89 | public function rename(string $path_from, string $path_to): bool 90 | { 91 | /** 92 | * @psalm-suppress PossiblyNullArgument 93 | */ 94 | return rename($path_from, $path_to, $this->context); 95 | } 96 | 97 | public function rmdir(string $path, int $options): bool 98 | { 99 | /** 100 | * @psalm-suppress PossiblyNullArgument 101 | */ 102 | return rmdir($path, $this->context); 103 | } 104 | 105 | /** 106 | * @psalm-suppress InvalidReturnType Unfortunately, I don't know what to return here. 107 | */ 108 | public function stream_cast(int $castAs): void 109 | { 110 | // ??? 111 | } 112 | 113 | public function stream_eof(): bool 114 | { 115 | /** 116 | * @psalm-suppress PossiblyNullArgument 117 | */ 118 | return feof($this->stream); 119 | } 120 | 121 | public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool 122 | { 123 | $this->filename = realpath($path) ?: $path; 124 | 125 | if ((self::STREAM_OPEN_FOR_INCLUDE & $options) === self::STREAM_OPEN_FOR_INCLUDE && function_exists( 126 | 'opcache_invalidate' 127 | )) { 128 | opcache_invalidate($path, false); 129 | } 130 | $this->stream = fopen( 131 | $path, 132 | $mode, 133 | ($options & STREAM_USE_PATH) === STREAM_USE_PATH, 134 | (self::STREAM_OPEN_FOR_INCLUDE & $options) === self::STREAM_OPEN_FOR_INCLUDE ? null : $this->context 135 | ); 136 | 137 | if (!is_resource($this->stream)) { 138 | return false; 139 | } 140 | 141 | if ($opened_path !== null) { 142 | $metaData = stream_get_meta_data($this->stream); 143 | $opened_path = $metaData['uri']; 144 | } 145 | return true; 146 | } 147 | 148 | public function stream_read(int $count): string|false 149 | { 150 | /** 151 | * @psalm-suppress PossiblyNullArgument 152 | */ 153 | return fread($this->stream, $count); 154 | } 155 | 156 | public function stream_seek(int $offset, int $whence = SEEK_SET): bool 157 | { 158 | /** 159 | * @psalm-suppress PossiblyNullArgument 160 | */ 161 | return fseek($this->stream, $offset, $whence) !== -1; 162 | } 163 | 164 | public function stream_set_option(int $option, int $arg1, int $arg2): bool 165 | { 166 | /** 167 | * @psalm-suppress PossiblyNullArgument 168 | */ 169 | return match ($option) { 170 | STREAM_OPTION_BLOCKING => stream_set_blocking($this->stream, $arg1 === STREAM_OPTION_BLOCKING), 171 | STREAM_OPTION_READ_TIMEOUT => stream_set_timeout($this->stream, $arg1, $arg2), 172 | STREAM_OPTION_WRITE_BUFFER => stream_set_write_buffer($this->stream, $arg2) === 0, 173 | default => false, 174 | }; 175 | } 176 | 177 | public function stream_stat(): array|false 178 | { 179 | /** 180 | * @psalm-suppress PossiblyNullArgument 181 | */ 182 | return fstat($this->stream); 183 | } 184 | 185 | public function stream_tell(): int 186 | { 187 | /** 188 | * @psalm-suppress PossiblyNullArgument 189 | */ 190 | return ftell($this->stream); 191 | } 192 | 193 | public function stream_write(string $data): int 194 | { 195 | /** 196 | * @psalm-suppress PossiblyNullArgument 197 | */ 198 | return fwrite($this->stream, $data); 199 | } 200 | 201 | public function url_stat(string $path, int $flags): array|false 202 | { 203 | try { 204 | if (($flags & STREAM_URL_STAT_QUIET) === STREAM_URL_STAT_QUIET) { 205 | return @stat($path); 206 | } 207 | return stat($path); 208 | } catch (Throwable $e) { 209 | if (($flags & STREAM_URL_STAT_QUIET) === STREAM_URL_STAT_QUIET) { 210 | return false; 211 | } 212 | trigger_error($e->getMessage(), E_USER_ERROR); 213 | } 214 | } 215 | 216 | public function stream_metadata(string $path, int $option, mixed $value): bool 217 | { 218 | /** @psalm-suppress MixedArgument */ 219 | return match ($option) { 220 | STREAM_META_TOUCH => touch($path, ...$value), 221 | STREAM_META_OWNER_NAME, STREAM_META_OWNER => chown($path, $value), 222 | STREAM_META_GROUP_NAME, STREAM_META_GROUP => chgrp($path, $value), 223 | STREAM_META_ACCESS => chmod($path, $value), 224 | default => false 225 | }; 226 | } 227 | 228 | public function stream_flush(): bool 229 | { 230 | /** 231 | * @psalm-suppress PossiblyNullArgument 232 | */ 233 | return fflush($this->stream); 234 | } 235 | 236 | public function stream_close(): void 237 | { 238 | /** 239 | * @psalm-suppress InvalidPropertyAssignmentValue 240 | */ 241 | if ($this->stream !== null) { 242 | fclose($this->stream); 243 | } 244 | $this->stream = null; 245 | } 246 | 247 | public function stream_lock(int $operation): bool 248 | { 249 | if ($operation === 0) { 250 | $operation = LOCK_EX; 251 | } 252 | 253 | /** 254 | * @psalm-suppress PossiblyNullArgument 255 | */ 256 | return flock($this->stream, $operation); 257 | } 258 | 259 | public function stream_truncate(int $new_size): bool 260 | { 261 | /** 262 | * @psalm-suppress PossiblyNullArgument 263 | */ 264 | return ftruncate($this->stream, $new_size); 265 | } 266 | 267 | public function unlink(string $path): bool 268 | { 269 | /** 270 | * @psalm-suppress PossiblyNullArgument 271 | */ 272 | return unlink($path, $this->context); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/Helper/StreamWrapper/StreamWrapperInterface.php: -------------------------------------------------------------------------------- 1 | decorated->$name = $value; 12 | } 13 | 14 | public function __get(string $name) 15 | { 16 | return $this->decorated->$name; 17 | } 18 | 19 | public function __call(string $name, array $arguments) 20 | { 21 | /** 22 | * @psalm-suppress MixedMethodCall 23 | */ 24 | return $this->decorated->$name(...$arguments); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/StartupPolicy/Collector/AllowAllCollectorPolicy.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private readonly array $conditions, 18 | ) { 19 | } 20 | 21 | public function satisfies(CollectorInterface $collector, object $event): bool 22 | { 23 | $condition = $this->conditions[$collector->getName()] ?? null; 24 | if ($condition === null) { 25 | return true; 26 | } 27 | 28 | return !$condition->match($event); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/StartupPolicy/Collector/CallableCollectorPolicy.php: -------------------------------------------------------------------------------- 1 | callable = $callable; 27 | } 28 | 29 | public function satisfies(CollectorInterface $collector, object $event): bool 30 | { 31 | return ($this->callable)($collector, $event); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/StartupPolicy/Collector/CollectorStartupPolicyInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private readonly array $conditions, 18 | ) { 19 | } 20 | 21 | public function satisfies(CollectorInterface $collector, object $event): bool 22 | { 23 | $condition = $this->conditions[$collector->getName()] ?? null; 24 | if ($condition === null) { 25 | return false; 26 | } 27 | 28 | return $condition->match($event); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/StartupPolicy/Condition/CommandNameCondition.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private readonly array $names, 18 | ) { 19 | } 20 | 21 | public function match(object $event): bool 22 | { 23 | if (!$event instanceof ApplicationStartup) { 24 | return false; 25 | } 26 | 27 | $name = (string) $event->commandName; 28 | 29 | foreach ($this->names as $pattern) { 30 | if ((new WildcardPattern($pattern, [':']))->match($name)) { 31 | return true; 32 | } 33 | } 34 | 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/StartupPolicy/Condition/ConditionInterface.php: -------------------------------------------------------------------------------- 1 | variableName); 21 | return $value !== false && in_array(strtolower($value), self::TRUE_VALUES, true); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/StartupPolicy/Condition/HeaderCondition.php: -------------------------------------------------------------------------------- 1 | getRequest(); 31 | 32 | return $request->hasHeader($this->headerName) 33 | && in_array(strtolower($request->getHeaderLine($this->headerName)), self::TRUE_VALUES, true); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/StartupPolicy/Condition/PredefinedCondition.php: -------------------------------------------------------------------------------- 1 | match; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/StartupPolicy/Condition/UriPathCondition.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private readonly array $paths, 18 | ) { 19 | } 20 | 21 | public function match(object $event): bool 22 | { 23 | if (!$event instanceof BeforeRequest) { 24 | return false; 25 | } 26 | 27 | $path = $event->getRequest()->getUri()->getPath(); 28 | 29 | foreach ($this->paths as $pattern) { 30 | if ((new WildcardPattern($pattern, ['/']))->match($path)) { 31 | return true; 32 | } 33 | } 34 | 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/StartupPolicy/Debugger/AllowDebuggerPolicy.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private readonly array $conditions; 15 | 16 | /** 17 | * @no-named-arguments 18 | */ 19 | public function __construct(ConditionInterface ...$policies) 20 | { 21 | $this->conditions = $policies; 22 | } 23 | 24 | public function satisfies(object $event): bool 25 | { 26 | foreach ($this->conditions as $policy) { 27 | if ($policy->match($event)) { 28 | return true; 29 | } 30 | } 31 | 32 | return false; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/StartupPolicy/Debugger/AlwaysOnDebuggerPolicy.php: -------------------------------------------------------------------------------- 1 | callable = $callable; 25 | } 26 | 27 | public function satisfies(object $event): bool 28 | { 29 | return ($this->callable)($event); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/StartupPolicy/Debugger/DebuggerStartupPolicyInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private readonly array $conditions; 15 | 16 | /** 17 | * @no-named-arguments 18 | */ 19 | public function __construct(ConditionInterface ...$policies) 20 | { 21 | $this->conditions = $policies; 22 | } 23 | 24 | public function satisfies(object $event): bool 25 | { 26 | foreach ($this->conditions as $policy) { 27 | if ($policy->match($event)) { 28 | return false; 29 | } 30 | } 31 | 32 | return true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Storage/FileStorage.php: -------------------------------------------------------------------------------- 1 | historySize = $historySize; 32 | } 33 | 34 | public function read(string $type, ?string $id = null): array 35 | { 36 | clearstatcache(); 37 | 38 | $dataFiles = $this->findFilesOrderedByModifiedTime( 39 | sprintf( 40 | '%s/**/%s/%s.json', 41 | $this->path, 42 | $id ?? '**', 43 | $type, 44 | ) 45 | ); 46 | 47 | $data = []; 48 | foreach ($dataFiles as $file) { 49 | $dir = dirname($file); 50 | $id = substr($dir, strlen(dirname($file, 2)) + 1); 51 | $content = file_get_contents($file); 52 | $data[$id] = $content === '' ? '' : json_decode($content, true, flags: JSON_THROW_ON_ERROR); 53 | } 54 | 55 | return $data; 56 | } 57 | 58 | public function write(string $id, array $data, array $objectsMap, array $summary): void 59 | { 60 | $basePath = $this->path . '/' . date('Y-m-d') . '/' . $id . '/'; 61 | 62 | try { 63 | FileHelper::ensureDirectory($basePath); 64 | file_put_contents($basePath . self::TYPE_DATA . '.json', $this->encode($data)); 65 | file_put_contents($basePath . self::TYPE_OBJECTS . '.json', $this->encode($objectsMap)); 66 | file_put_contents($basePath . self::TYPE_SUMMARY . '.json', $this->encode($summary)); 67 | } finally { 68 | $this->gc(); 69 | } 70 | } 71 | 72 | public function clear(): void 73 | { 74 | FileHelper::removeDirectory($this->path); 75 | } 76 | 77 | /** 78 | * Removes obsolete data files 79 | */ 80 | private function gc(): void 81 | { 82 | $summaryFiles = $this->findFilesOrderedByModifiedTime($this->path . '/**/**/summary.json'); 83 | if (empty($summaryFiles) || count($summaryFiles) <= $this->historySize) { 84 | return; 85 | } 86 | 87 | $excessFiles = array_slice($summaryFiles, $this->historySize); 88 | foreach ($excessFiles as $file) { 89 | $path1 = dirname($file); 90 | $path2 = dirname($file, 2); 91 | $path3 = dirname($file, 3); 92 | $resource = substr($path1, strlen($path3)); 93 | 94 | FileHelper::removeDirectory($this->path . $resource); 95 | 96 | // Clean empty group directories 97 | $group = substr($path2, strlen($path3)); 98 | if (FileHelper::isEmptyDirectory($this->path . $group)) { 99 | FileHelper::removeDirectory($this->path . $group); 100 | } 101 | } 102 | } 103 | 104 | /** 105 | * @return string[] 106 | */ 107 | private function findFilesOrderedByModifiedTime(string $pattern): array 108 | { 109 | $files = glob($pattern, GLOB_NOSORT); 110 | if ($files === false) { 111 | return []; 112 | } 113 | 114 | usort( 115 | $files, 116 | static fn (string $a, string $b) => filemtime($b) <=> filemtime($a) 117 | ); 118 | return $files; 119 | } 120 | 121 | private function encode(mixed $value): string 122 | { 123 | return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Storage/MemoryStorage.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | private array $storage = []; 13 | 14 | public function read(string $type, ?string $id = null): array 15 | { 16 | if ($id === null) { 17 | return array_map( 18 | static fn (array $item): array => $item[$type] ?? [], 19 | $this->storage, 20 | ); 21 | } 22 | return [$id => $this->storage[$id][$type] ?? []]; 23 | } 24 | 25 | public function write(string $id, array $data, array $objectsMap, array $summary): void 26 | { 27 | $this->storage[$id] = [ 28 | 'data' => $data, 29 | 'objects' => $objectsMap, 30 | 'summary' => $summary, 31 | ]; 32 | } 33 | 34 | public function clear(): void 35 | { 36 | $this->storage = []; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Storage/StorageInterface.php: -------------------------------------------------------------------------------- 1 | getCollector(); 17 | 18 | $collector->startup(); 19 | $this->collectTestData($collector); 20 | $data = $collector->getCollected(); 21 | if ($collector instanceof SummaryCollectorInterface) { 22 | $summaryData = $collector->getSummary(); 23 | } 24 | $collector->shutdown(); 25 | 26 | $this->assertSame($collector::class, $collector->getName()); 27 | $this->checkCollectedData($data); 28 | if ($collector instanceof SummaryCollectorInterface) { 29 | $this->checkSummaryData($summaryData); 30 | } 31 | } 32 | 33 | public function testEmptyCollector(): void 34 | { 35 | $collector = $this->getCollector(); 36 | 37 | $this->assertEquals([], $collector->getCollected()); 38 | if ($collector instanceof SummaryCollectorInterface) { 39 | $this->assertEquals([], $collector->getSummary()); 40 | } 41 | } 42 | 43 | public function testInactiveCollector(): void 44 | { 45 | $collector = $this->getCollector(); 46 | 47 | $this->collectTestData($collector); 48 | 49 | $this->assertEquals([], $collector->getCollected()); 50 | if ($collector instanceof SummaryCollectorInterface) { 51 | $this->assertEquals([], $collector->getSummary()); 52 | } 53 | } 54 | 55 | abstract protected function getCollector(): CollectorInterface; 56 | 57 | abstract protected function collectTestData(CollectorInterface $collector): void; 58 | 59 | protected function checkCollectedData(array $data): void 60 | { 61 | $this->assertNotEmpty($data); 62 | } 63 | 64 | protected function checkSummaryData(array $data): void 65 | { 66 | $this->assertNotEmpty($data); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Support/Stub/BrokenProxyImplementation.php: -------------------------------------------------------------------------------- 1 | decorated = new StreamWrapper(); 25 | $this->decorated->context = $this->context; 26 | } 27 | 28 | public function __destruct() 29 | { 30 | self::unregister(); 31 | } 32 | 33 | public function __call(string $name, array $arguments) 34 | { 35 | try { 36 | self::unregister(); 37 | return $this->decorated->{$name}(...$arguments); 38 | } finally { 39 | self::register(); 40 | } 41 | } 42 | 43 | public function __get(string $name) 44 | { 45 | return $this->decorated->{$name}; 46 | } 47 | 48 | public static function register(): void 49 | { 50 | if (self::$registered) { 51 | return; 52 | } 53 | /** 54 | * It's important to trigger autoloader before unregistering the file stream handler 55 | */ 56 | class_exists(StreamWrapper::class); 57 | stream_wrapper_unregister('php'); 58 | stream_wrapper_register('php', self::class, STREAM_IS_URL); 59 | 60 | self::$registered = true; 61 | } 62 | 63 | public static function unregister(): void 64 | { 65 | if (!self::$registered) { 66 | return; 67 | } 68 | @stream_wrapper_restore('php'); 69 | self::$registered = false; 70 | } 71 | 72 | public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool 73 | { 74 | return $this->__call(__FUNCTION__, func_get_args()); 75 | } 76 | 77 | public function stream_read(int $count): string|false 78 | { 79 | return $this->__call(__FUNCTION__, func_get_args()); 80 | } 81 | 82 | public function stream_set_option(int $option, int $arg1, ?int $arg2): bool 83 | { 84 | return $this->__call(__FUNCTION__, func_get_args()); 85 | } 86 | 87 | public function stream_tell(): int 88 | { 89 | return $this->__call(__FUNCTION__, func_get_args()); 90 | } 91 | 92 | public function stream_eof(): bool 93 | { 94 | return $this->__call(__FUNCTION__, func_get_args()); 95 | } 96 | 97 | public function stream_seek(int $offset, int $whence = SEEK_SET): bool 98 | { 99 | return $this->__call(__FUNCTION__, func_get_args()); 100 | } 101 | 102 | public function stream_cast(int $castAs) 103 | { 104 | return $this->__call(__FUNCTION__, func_get_args()); 105 | } 106 | 107 | public function stream_stat(): array|false 108 | { 109 | return $this->__call(__FUNCTION__, func_get_args()); 110 | } 111 | 112 | public function dir_closedir(): bool 113 | { 114 | return $this->__call(__FUNCTION__, func_get_args()); 115 | } 116 | 117 | public function dir_opendir(string $path, int $options): bool 118 | { 119 | return $this->__call(__FUNCTION__, func_get_args()); 120 | } 121 | 122 | public function dir_readdir(): false|string 123 | { 124 | return $this->__call(__FUNCTION__, func_get_args()); 125 | } 126 | 127 | public function dir_rewinddir(): bool 128 | { 129 | return $this->__call(__FUNCTION__, func_get_args()); 130 | } 131 | 132 | public function mkdir(string $path, int $mode, int $options): bool 133 | { 134 | return $this->__call(__FUNCTION__, func_get_args()); 135 | } 136 | 137 | public function rename(string $path_from, string $path_to): bool 138 | { 139 | return $this->__call(__FUNCTION__, func_get_args()); 140 | } 141 | 142 | public function rmdir(string $path, int $options): bool 143 | { 144 | return $this->__call(__FUNCTION__, func_get_args()); 145 | } 146 | 147 | public function stream_close(): void 148 | { 149 | $this->__call(__FUNCTION__, func_get_args()); 150 | } 151 | 152 | public function stream_flush(): bool 153 | { 154 | return $this->__call(__FUNCTION__, func_get_args()); 155 | } 156 | 157 | public function stream_lock(int $operation): bool 158 | { 159 | return $this->__call(__FUNCTION__, func_get_args()); 160 | } 161 | 162 | public function stream_metadata(string $path, int $option, mixed $value): bool 163 | { 164 | return $this->__call(__FUNCTION__, func_get_args()); 165 | } 166 | 167 | public function stream_truncate(int $new_size): bool 168 | { 169 | return $this->__call(__FUNCTION__, func_get_args()); 170 | } 171 | 172 | public function stream_write(string $data): int 173 | { 174 | return $this->__call(__FUNCTION__, func_get_args()); 175 | } 176 | 177 | public function unlink(string $path): bool 178 | { 179 | return $this->__call(__FUNCTION__, func_get_args()); 180 | } 181 | 182 | public function url_stat(string $path, int $flags): array|false 183 | { 184 | return $this->__call(__FUNCTION__, func_get_args()); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /tests/Support/Stub/ThreeProperties.php: -------------------------------------------------------------------------------- 1 | name; 20 | } 21 | 22 | public function startup(): void 23 | { 24 | } 25 | 26 | public function shutdown(): void 27 | { 28 | } 29 | 30 | public function getCollected(): array 31 | { 32 | return $this->collected; 33 | } 34 | } 35 | --------------------------------------------------------------------------------