├── .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 |
4 |
5 |
Yii Debug Extension
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/yii-debug)
10 | [](https://packagist.org/packages/yiisoft/yii-debug)
11 | [](https://github.com/yiisoft/yii-debug/actions/workflows/build.yml)
12 | [](https://codecov.io/gh/yiisoft/yii-debug)
13 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-debug/master)
14 | [](https://github.com/yiisoft/yii-debug/actions?query=workflow%3A%22static+analysis%22)
15 | [](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 | [](https://opencollective.com/yiisoft)
72 |
73 | ## Follow updates
74 |
75 | [](https://www.yiiframework.com/)
76 | [](https://twitter.com/yiiframework)
77 | [](https://t.me/yii3en)
78 | [](https://www.facebook.com/groups/yiitalk)
79 | [](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 |
--------------------------------------------------------------------------------