├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .php-cs-fixer.php ├── CODEOWNERS ├── LICENSE ├── README.md ├── composer.json ├── example.php ├── images └── 4c6e5fee-da7e-4bc5-a898-f19d12acb005.jpg ├── phpunit.xml.dist ├── src ├── Addons │ ├── AddonInterface.php │ ├── Environment.php │ └── Headers.php ├── Catcher.php ├── Event.php ├── EventPayload.php ├── EventPayloadBuilder.php ├── Exception │ └── SilencedErrorException.php ├── Handler.php ├── Options.php ├── Serializer.php ├── Severity.php ├── StacktraceFrameBuilder.php └── Transport │ ├── CurlTransport.php │ ├── GuzzleTransport.php │ └── TransportInterface.php └── tests └── Unit ├── EventPayloadBuilderTest.php ├── OptionsTest.php ├── SerializerTest.php └── StacktraceFrameBuilderTest.php /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | name: CI 3 | jobs: 4 | linter: 5 | name: PHP CodeSniffer / CS Fixer 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | 10 | - name: Setup PHP with tools 11 | uses: shivammathur/setup-php@v2 12 | with: 13 | php-version: '7.4' 14 | tools: php-cs-fixer 15 | 16 | - name: PHP CS Fixer 17 | run: php-cs-fixer fix --config=.php-cs-fixer.php --using-cache=no --verbose --dry-run 18 | 19 | build: 20 | name: Build and UnitTest 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | php-versions: [ '7.3', '7.4', '8.0', '8.1' ] 25 | 26 | steps: 27 | # Checks out a copy of your repository on the ubuntu-latest machine 28 | - name: Checkout code 29 | uses: actions/checkout@v2 30 | 31 | # Sets PHP version from matrix 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-versions: ${{ matrix.php-versions }} 36 | tools: phpunit 37 | 38 | # Validate composer files 39 | - name: Validate composer.json and composer.lock 40 | run: composer validate 41 | 42 | # Cache packages 43 | - name: Cache Composer packages 44 | id: composer-cache 45 | uses: actions/cache@v2 46 | with: 47 | path: vendor 48 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 49 | restore-keys: | 50 | ${{ runner.os }}-php- 51 | 52 | # Install dependencies 53 | - name: Install dependencies 54 | if: steps.composer-cache.outputs.cache-hit != 'true' 55 | run: composer install --prefer-dist --no-progress 56 | 57 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 58 | # Docs: https://getcomposer.org/doc/articles/scripts.md 59 | - name: Run test with PHP ${{ matrix.php-versions }} 60 | run: composer run-script test 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor/ 3 | .idea/ 4 | .DS_Store 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setUsingCache(false) 7 | ->setRules([ 8 | '@PSR2' => true, 9 | 'array_syntax' => ['syntax' => 'short'], 10 | 'blank_line_before_statement' => true, 11 | 'cast_spaces' => true, 12 | 'concat_space' => ['spacing' => 'one'], 13 | 'declare_equal_normalize' => true, 14 | 'line_ending' => false, 15 | 'method_argument_space' => true, 16 | 'multiline_whitespace_before_semicolons' => false, 17 | 'no_blank_lines_before_namespace' => false, 18 | 'no_empty_statement' => true, 19 | 'no_leading_import_slash' => true, 20 | 'no_leading_namespace_whitespace' => true, 21 | 'no_multiline_whitespace_around_double_arrow' => true, 22 | 'no_trailing_comma_in_list_call' => true, 23 | 'no_trailing_comma_in_singleline_array' => true, 24 | 'no_unused_imports' => true, 25 | 'no_whitespace_before_comma_in_array' => true, 26 | 'no_whitespace_in_blank_line' => true, 27 | 'ordered_imports' => true, 28 | 'phpdoc_add_missing_param_annotation' => true, 29 | 'phpdoc_align' => true, 30 | 'phpdoc_indent' => true, 31 | 'phpdoc_no_empty_return' => false, 32 | 'phpdoc_order' => false, 33 | 'phpdoc_separation' => true, 34 | 'phpdoc_scalar' => true, 35 | 'return_type_declaration' => true, 36 | 'short_scalar_cast' => true, 37 | 'single_import_per_statement' => false, 38 | 'single_quote' => true, 39 | 'ternary_operator_spaces' => true, 40 | 'trim_array_spaces' => true, 41 | ]) 42 | ->setFinder( 43 | PhpCsFixer\Finder::create() 44 | ->in(__DIR__) 45 | ); 46 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @neSpecc 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 CodeX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hawk PHP 2 | 3 | PHP errors Catcher for [Hawk.so](https://hawk.so). 4 | 5 | ![](./images/4c6e5fee-da7e-4bc5-a898-f19d12acb005.jpg) 6 | 7 | ## Setup 8 | 9 | 1. [Register](https://garage.hawk.so/sign-up) an account, create a Project and get an Integration Token. 10 | 11 | 2. Install SDK via [composer](https://getcomposer.org) to install the Catcher 12 | 13 | Catcher provides support for PHP 7.2 or later 14 | 15 | ```bash 16 | $ composer require codex-team/hawk.php 17 | ``` 18 | 19 | ### Configuration 20 | 21 | ```php 22 | \Hawk\Catcher::init([ 23 | 'integrationToken' => 'your integration token' 24 | ]); 25 | ``` 26 | 27 | After initialization you can set `user` or `context` for any event that will be send to Hawk 28 | 29 | ```php 30 | \Hawk\Catcher::get() 31 | ->setUser([ 32 | 'name' => 'user name', 33 | 'photo' => 'user photo', 34 | ]) 35 | ->setContext([ 36 | ... 37 | ]); 38 | ``` 39 | 40 | 41 | ### Send events and exceptions manually 42 | 43 | Use `sendException` method to send any caught exception 44 | 45 | ```php 46 | try { 47 | throw new Exception("Error Processing Request", 1); 48 | } catch (Exception $e) { 49 | \Hawk\Catcher::get()->sendException($e); 50 | } 51 | ``` 52 | 53 | Use `sendEvent` method to send any data (logs, notices or something else) 54 | 55 | ```php 56 | \Hawk\Catcher::get()->sendMessage('your message', [ 57 | ... // Context 58 | ]); 59 | ``` 60 | 61 | ### Filtering sensitive information 62 | 63 | Use the `beforeSend` hook to filter any data you don't want to send to Hawk. Use setters to clear any property. 64 | 65 | ```php 66 | \Hawk\Catcher::init([ 67 | // ... 68 | 'beforeSend' => function (\Hawk\EventPayload $eventPayload) { 69 | $user = $eventPayload->getUser(); 70 | 71 | if (!empty($user['email'])){ 72 | unset($user['email']); 73 | 74 | $eventPayload->setUser($user); 75 | } 76 | 77 | return $eventPayload; 78 | } 79 | ]); 80 | ``` 81 | 82 | ## Issues and improvements 83 | 84 | Feel free to ask questions or improve the project. 85 | 86 | ## Links 87 | 88 | Repository: https://github.com/codex-team/hawk.php 89 | 90 | Report a bug: https://github.com/codex-team/hawk.php/issues 91 | 92 | Composer Package: https://packagist.org/packages/codex-team/hawk.php 93 | 94 | CodeX Team: https://codex.so 95 | 96 | ## License 97 | 98 | MIT 99 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codex-team/hawk.php", 3 | "description": "PHP errors Catcher module for Hawk.so", 4 | "keywords": ["hawk", "php", "error", "catcher"], 5 | "type": "library", 6 | "version": "2.2.8", 7 | "license": "MIT", 8 | "require": { 9 | "ext-curl": "*", 10 | "ext-json": "*", 11 | "php": "^7.2 || ^8.0", 12 | "jean85/pretty-package-versions": "^1.5 || ^2.0" 13 | }, 14 | "require-dev": { 15 | "phpunit/phpunit": "^8.2", 16 | "friendsofphp/php-cs-fixer": "^2.15", 17 | "symfony/var-dumper": "^5.2" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Hawk\\": "src/" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "Hawk\\Tests\\": "tests/" 27 | } 28 | }, 29 | "scripts": { 30 | "test": "vendor/bin/phpunit --testdox", 31 | "csfix": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --using-cache=no --verbose" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example.php: -------------------------------------------------------------------------------- 1 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9qZWN0SWQiOiI2MGJmNzFjMmZmODQ5MWFmNWIwZWZiYWUiLCJpYXQiOjE2MjMxNTkyMzR9.dwFU0VTdKsnDDMTKmGUXkxCs0sH6jsj55uPpqCbXBHA', 16 | // 'url' => 'http://localhost:3000/' 17 | ]); 18 | 19 | function randStr() 20 | { 21 | return bin2hex(openssl_random_pseudo_bytes(8)); 22 | } 23 | 24 | class Test 25 | { 26 | public function __construct() 27 | { 28 | } 29 | 30 | public function test($aTest) 31 | { 32 | return self::testStatic($aTest); 33 | } 34 | 35 | public static function testStatic($aTest) 36 | { 37 | return divZero($aTest); 38 | } 39 | } 40 | 41 | $t = new Test(); 42 | 43 | function divZero($aDiv) 44 | { 45 | $b = 0; 46 | 47 | $randA = randStr(); 48 | 49 | fail($randA); 50 | 51 | return $aDiv / $b; 52 | } 53 | 54 | function fail($numFail) 55 | { 56 | throw new Exception('Error ' . $numFail . ' at ' . date('j F Y h:i:s')); 57 | } 58 | 59 | 60 | $t->test(5); 61 | -------------------------------------------------------------------------------- /images/4c6e5fee-da7e-4bc5-a898-f19d12acb005.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codex-team/hawk.php/92a0da7a7c004e450bc56a2ee363bb9a4f1c3717/images/4c6e5fee-da7e-4bc5-a898-f19d12acb005.jpg -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | ./tests 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Addons/AddonInterface.php: -------------------------------------------------------------------------------- 1 | addHostname(); 30 | 31 | return $this->environment; 32 | } 33 | 34 | private function addHostname(): void 35 | { 36 | $hostname = gethostname(); 37 | 38 | if ($hostname !== false) { 39 | $this->environment['hostname'] = $hostname; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Addons/Headers.php: -------------------------------------------------------------------------------- 1 | $value) { 32 | if (substr($key, 0, 5) == 'HTTP_') { 33 | $key = str_replace( 34 | ' ', 35 | '-', 36 | ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))) 37 | ); 38 | $result[$key] = $value; 39 | } else { 40 | $result[$key] = $value; 41 | } 42 | } 43 | } 44 | 45 | unset($result['Cookie']); 46 | 47 | return $result; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Catcher.php: -------------------------------------------------------------------------------- 1 | handler->setUser($user); 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * @param array $context 81 | * 82 | * @return $this 83 | */ 84 | public function setContext(array $context): self 85 | { 86 | $this->handler->setContext($context); 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * @param string $message 93 | * @param array $context 94 | * 95 | * @example 96 | * \Hawk\Catcher::get() 97 | * ->sendMessage('my special message', [ 98 | * ... // context 99 | * ]) 100 | */ 101 | public function sendMessage(string $message, array $context = []): void 102 | { 103 | $this->handler->sendEvent([ 104 | 'title' => $message, 105 | 'context' => $context 106 | ]); 107 | } 108 | 109 | /** 110 | * @param Throwable $throwable 111 | * @param array $context 112 | * 113 | * @throws Throwable 114 | * 115 | * @example 116 | * \Hawk\Catcher::get() 117 | * ->sendException($exception, [ 118 | * ... // context 119 | * ]) 120 | */ 121 | public function sendException(Throwable $throwable, array $context = []) 122 | { 123 | $this->handler->handleException($throwable, $context); 124 | } 125 | 126 | /** 127 | * @example 128 | * \Hawk\Catcher::get() 129 | * ->sendEvent([ 130 | * ... // payload 131 | * ]) 132 | * 133 | * @param array $payload 134 | */ 135 | public function sendEvent(array $payload): void 136 | { 137 | $this->handler->sendEvent($payload); 138 | } 139 | 140 | /** 141 | * @param array $options 142 | */ 143 | private function __construct(array $options) 144 | { 145 | $options = new Options($options); 146 | 147 | /** 148 | * Init stacktrace frames builder and inject serializer 149 | */ 150 | $serializer = new Serializer(); 151 | $stacktraceBuilder = new StacktraceFrameBuilder($serializer); 152 | 153 | /** 154 | * Prepare Event payload builder 155 | */ 156 | $builder = new EventPayloadBuilder($stacktraceBuilder); 157 | $builder->registerAddon(new Headers()); 158 | $builder->registerAddon(new Environment()); 159 | 160 | $transport = new CurlTransport($options->getUrl(), $options->getTimeout()); 161 | 162 | $this->handler = new Handler($options, $transport, $builder); 163 | 164 | $this->handler->registerErrorHandler(); 165 | $this->handler->registerExceptionHandler(); 166 | $this->handler->registerFatalHandler(); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Event.php: -------------------------------------------------------------------------------- 1 | integrationToken = $integrationToken; 40 | $this->eventPayload = $eventPayload; 41 | } 42 | 43 | /** 44 | * Returns event payload 45 | * 46 | * @return EventPayload 47 | */ 48 | public function getEventPayload(): EventPayload 49 | { 50 | return $this->eventPayload; 51 | } 52 | 53 | /** 54 | * @return array 55 | */ 56 | public function jsonSerialize(): array 57 | { 58 | return [ 59 | 'token' => $this->integrationToken, 60 | 'catcherType' => $this->catcherType, 61 | 'payload' => $this->getEventPayload()->jsonSerialize() 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/EventPayload.php: -------------------------------------------------------------------------------- 1 | $value) { 87 | if (property_exists($this, $prop)) { 88 | $this->{$prop} = $value; 89 | } 90 | } 91 | 92 | $this->catcherVersion = PrettyVersions::getVersion('codex-team/hawk.php')->getPrettyVersion(); 93 | } 94 | 95 | /** 96 | * Returns event title 97 | * 98 | * @return string 99 | */ 100 | public function getTitle(): string 101 | { 102 | return $this->title; 103 | } 104 | 105 | /** 106 | * @param string $title 107 | * 108 | * @return $this 109 | */ 110 | public function setTitle(string $title): self 111 | { 112 | $this->title = $title; 113 | 114 | return $this; 115 | } 116 | 117 | /** 118 | * Returns errors' type 119 | * 120 | * @return null|Severity 121 | */ 122 | public function getType(): ?Severity 123 | { 124 | return $this->type; 125 | } 126 | 127 | /** 128 | * @param int $type 129 | * 130 | * @return $this 131 | */ 132 | public function setType(int $severity): self 133 | { 134 | $this->type = Severity::fromError($severity); 135 | 136 | return $this; 137 | } 138 | 139 | /** 140 | * Returns errors' description 141 | * 142 | * @return string 143 | */ 144 | public function getDescription(): string 145 | { 146 | return $this->description; 147 | } 148 | 149 | /** 150 | * @param string $description 151 | * 152 | * @return $this 153 | */ 154 | public function setDescription(string $description): self 155 | { 156 | $this->description = $description; 157 | 158 | return $this; 159 | } 160 | 161 | /** 162 | * Returns errors' backtrace 163 | * 164 | * @return array 165 | */ 166 | public function getBacktrace(): array 167 | { 168 | return $this->backtrace; 169 | } 170 | 171 | /** 172 | * @param array $backtrace 173 | * 174 | * @return $this 175 | */ 176 | public function setBacktrace(array $backtrace): self 177 | { 178 | $this->backtrace = $backtrace; 179 | 180 | return $this; 181 | } 182 | 183 | /** 184 | * Returns event addons 185 | * 186 | * @return array 187 | */ 188 | public function getAddons(): array 189 | { 190 | return $this->addons; 191 | } 192 | 193 | /** 194 | * @param array $addons 195 | * 196 | * @return $this 197 | */ 198 | public function setAddons(array $addons): self 199 | { 200 | $this->addons = $addons; 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Returns release version 207 | * 208 | * @return string 209 | */ 210 | public function getRelease(): string 211 | { 212 | return $this->release; 213 | } 214 | 215 | /** 216 | * @param string $release 217 | * 218 | * @return $this 219 | */ 220 | public function setRelease(string $release): self 221 | { 222 | $this->release = $release; 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * Returns user, if passed on event 229 | * 230 | * @return array 231 | */ 232 | public function getUser(): array 233 | { 234 | return $this->user; 235 | } 236 | 237 | /** 238 | * @param array $user 239 | * 240 | * @return $this 241 | */ 242 | public function setUser(array $user): self 243 | { 244 | $this->user = $user; 245 | 246 | return $this; 247 | } 248 | 249 | /** 250 | * Returns event context (any additional data) 251 | * 252 | * @return array 253 | */ 254 | public function getContext(): array 255 | { 256 | return $this->context; 257 | } 258 | 259 | /** 260 | * @param array $context 261 | * 262 | * @return $this 263 | */ 264 | public function setContext(array $context): self 265 | { 266 | $this->context = $context; 267 | 268 | return $this; 269 | } 270 | 271 | /** 272 | * @return string 273 | */ 274 | public function getCatcherVersion(): string 275 | { 276 | return $this->catcherVersion; 277 | } 278 | 279 | /** 280 | * @return array|mixed 281 | */ 282 | public function jsonSerialize() 283 | { 284 | return $this->toArray(); 285 | } 286 | 287 | /** 288 | * @return array 289 | */ 290 | private function toArray(): array 291 | { 292 | return [ 293 | 'title' => $this->getTitle(), 294 | 'type' => $this->getType() ? $this->getType()->getValue() : '', 295 | 'description' => $this->getDescription(), 296 | 'backtrace' => $this->getBacktrace(), 297 | 'addons' => $this->getAddons(), 298 | 'release' => $this->getRelease(), 299 | 'user' => $this->getUser(), 300 | 'context' => $this->getContext(), 301 | 'catcherVersion' => $this->getCatcherVersion() 302 | ]; 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/EventPayloadBuilder.php: -------------------------------------------------------------------------------- 1 | stacktraceFrameBuilder = $stacktraceFrameBuilder; 36 | } 37 | 38 | /** 39 | * Adds addon resolver to the list 40 | * 41 | * @param AddonInterface $addon 42 | * 43 | * @return $this 44 | */ 45 | public function registerAddon(AddonInterface $addon): self 46 | { 47 | $this->addonsResolvers[] = $addon; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Returns EventPayload object 54 | * 55 | * @param array $data - event payload 56 | * 57 | * @return EventPayload 58 | */ 59 | public function create(array $data): EventPayload 60 | { 61 | $eventPayload = new EventPayload(); 62 | 63 | if (!empty($data['title'])) { 64 | $eventPayload->setTitle($data['title']); 65 | } 66 | 67 | $eventPayload->setContext($data['context']); 68 | $eventPayload->setUser($data['user']); 69 | 70 | if (isset($data['exception']) && $data['exception'] instanceof \Throwable) { 71 | $exception = $data['exception']; 72 | $stacktrace = $this->stacktraceFrameBuilder->buildStack($exception); 73 | 74 | $eventPayload->setTitle($exception->getMessage() ?: get_class($exception)); 75 | } else { 76 | $stacktrace = debug_backtrace(); 77 | } 78 | 79 | if (isset($data['type'])) { 80 | $eventPayload->setType($data['type']); 81 | } 82 | 83 | $eventPayload->setBacktrace($stacktrace); 84 | 85 | // Resolve addons 86 | $eventPayload->setAddons($this->resolveAddons()); 87 | 88 | return $eventPayload; 89 | } 90 | 91 | /** 92 | * Resolves addons list and returns array 93 | * 94 | * @return array 95 | */ 96 | private function resolveAddons(): array 97 | { 98 | $result = []; 99 | 100 | /** 101 | * @var string $key 102 | * @var AddonInterface $resolver 103 | */ 104 | foreach ($this->addonsResolvers as $key => $resolver) { 105 | $result[$resolver->getName()] = $resolver->resolve(); 106 | } 107 | 108 | return $result; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Exception/SilencedErrorException.php: -------------------------------------------------------------------------------- 1 | 'Deprecated', 71 | \E_USER_DEPRECATED => 'User Deprecated', 72 | \E_NOTICE => 'Notice', 73 | \E_USER_NOTICE => 'User Notice', 74 | \E_STRICT => 'Runtime Notice', 75 | \E_WARNING => 'Warning', 76 | \E_USER_WARNING => 'User Warning', 77 | \E_COMPILE_WARNING => 'Compile Warning', 78 | \E_CORE_WARNING => 'Core Warning', 79 | \E_USER_ERROR => 'User Error', 80 | \E_RECOVERABLE_ERROR => 'Catchable Fatal Error', 81 | \E_COMPILE_ERROR => 'Compile Error', 82 | \E_PARSE => 'Parse Error', 83 | \E_ERROR => 'Error', 84 | \E_CORE_ERROR => 'Core Error', 85 | ]; 86 | 87 | public function __construct( 88 | Options $options, 89 | TransportInterface $transport, 90 | EventPayloadBuilder $eventPayloadBuilder 91 | ) { 92 | $this->options = $options; 93 | $this->transport = $transport; 94 | $this->eventPayloadBuilder = $eventPayloadBuilder; 95 | } 96 | 97 | /** 98 | * Attach user data for event logging. 99 | * 100 | * @param array $user 101 | */ 102 | public function setUser(array $user): void 103 | { 104 | $this->user = $user; 105 | } 106 | 107 | /** 108 | * Attach contextual data to provide more details about the event. 109 | * 110 | * @param array $context 111 | */ 112 | public function setContext(array $context): void 113 | { 114 | $this->context = $context; 115 | } 116 | 117 | /** 118 | * Register the error handler once to handle PHP errors. 119 | */ 120 | public function registerErrorHandler(): self 121 | { 122 | if ($this->isErrorHandlerRegistered) { 123 | return $this; 124 | } 125 | 126 | $errorHandlerCallback = \Closure::fromCallable([$this, 'handleError']); 127 | 128 | $this->previousErrorHandler = set_error_handler($errorHandlerCallback); 129 | if (null === $this->previousErrorHandler) { 130 | restore_error_handler(); 131 | set_error_handler($errorHandlerCallback, $this->options->getErrorTypes()); 132 | } 133 | 134 | $this->isErrorHandlerRegistered = true; 135 | 136 | return $this; 137 | } 138 | 139 | /** 140 | * Register the exception handler once to manage uncaught exceptions. 141 | */ 142 | public function registerExceptionHandler(): self 143 | { 144 | if ($this->isExceptionHandlerRegistered) { 145 | return $this; 146 | } 147 | 148 | $exceptionHandlerCallback = \Closure::fromCallable([$this, 'handleException']); 149 | 150 | $this->previousExceptionHandler = set_exception_handler($exceptionHandlerCallback); 151 | $this->isExceptionHandlerRegistered = true; 152 | 153 | return $this; 154 | } 155 | 156 | /** 157 | * Register the fatal error handler to catch shutdown errors. 158 | */ 159 | public function registerFatalHandler(): self 160 | { 161 | if ($this->isFatalHandlerRegistered) { 162 | return $this; 163 | } 164 | 165 | register_shutdown_function(\Closure::fromCallable([$this, 'handleFatal'])); 166 | $this->isFatalHandlerRegistered = true; 167 | 168 | return $this; 169 | } 170 | 171 | /** 172 | * Handle PHP errors, convert them to exceptions, and send the event. 173 | */ 174 | public function handleError(int $level, string $message, string $file, int $line): bool 175 | { 176 | $isSilencedError = 0 === error_reporting(); 177 | 178 | if (\PHP_MAJOR_VERSION >= 8) { 179 | // Detect if the error was silenced in PHP 8+ 180 | $isSilencedError = 0 === (error_reporting() & ~self::PHP8_FATAL_ERRORS); 181 | 182 | if ($level === (self::PHP8_FATAL_ERRORS & $level)) { 183 | $isSilencedError = false; 184 | } 185 | } 186 | 187 | if ($isSilencedError) { 188 | $exception = new SilencedErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); 189 | } else { 190 | $exception = new \ErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); 191 | } 192 | 193 | $data = [ 194 | 'exception' => $exception, 195 | 'context' => $this->context, 196 | 'user' => $this->user, 197 | 'type' => $exception->getSeverity() 198 | ]; 199 | 200 | $eventPayload = $this->eventPayloadBuilder->create($data); 201 | $event = $this->buildEvent($eventPayload); 202 | 203 | if ($event !== null && $this->shouldHandleError($level, $isSilencedError)) { 204 | $this->send($event); 205 | } 206 | 207 | if (null !== $this->previousErrorHandler) { 208 | return false !== ($this->previousErrorHandler)($level, $message, $file, $line); 209 | } 210 | 211 | return false; 212 | } 213 | 214 | /** 215 | * Handle uncaught exceptions and send the event. 216 | * 217 | * @throws \Throwable 218 | */ 219 | public function handleException(\Throwable $exception, array $context = []): void 220 | { 221 | $data = [ 222 | 'exception' => $exception, 223 | 'context' => array_merge($this->context, $context), 224 | 'user' => $this->user 225 | ]; 226 | 227 | $eventPayload = $this->eventPayloadBuilder->create($data); 228 | $event = $this->buildEvent($eventPayload); 229 | 230 | if ($event !== null) { 231 | $this->send($event); 232 | } 233 | 234 | $previousExceptionHandlerException = $exception; 235 | 236 | $previousExceptionHandler = $this->previousExceptionHandler; 237 | $this->previousExceptionHandler = null; 238 | 239 | try { 240 | if (null !== $previousExceptionHandler) { 241 | $previousExceptionHandler($exception); 242 | 243 | return; 244 | } 245 | } catch (\Throwable $previousExceptionHandlerException) { 246 | // This `catch` block ensures that the $previousExceptionHandlerException 247 | // variable is overwritten with the newly caught exception. 248 | } 249 | 250 | // If the current exception is the same as the one handled 251 | // by the previous exception handler, we pass it back to the 252 | // native PHP handler to avoid an infinite loop. 253 | if ($exception === $previousExceptionHandlerException) { 254 | // Disable the fatal error handler to prevent the error from being reported twice. 255 | $this->disableFatalErrorHandler = true; 256 | 257 | throw $exception; 258 | } 259 | 260 | $this->handleException($previousExceptionHandlerException); 261 | } 262 | 263 | /** 264 | * Handle fatal errors that occur during script shutdown. 265 | */ 266 | public function handleFatal(): void 267 | { 268 | if ($this->disableFatalErrorHandler) { 269 | return; 270 | } 271 | 272 | $error = error_get_last(); 273 | 274 | if ( 275 | $error === null 276 | || is_array($error) && $error['type'] && (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING) 277 | ) { 278 | return; 279 | } 280 | 281 | $payload = [ 282 | 'exception' => new \ErrorException( 283 | $error['message'], 284 | 0, 285 | $error['type'], 286 | $error['file'], 287 | $error['line'] 288 | ), 289 | 'context' => $this->context, 290 | 'user' => $this->user 291 | ]; 292 | 293 | $eventPayload = $this->eventPayloadBuilder->create($payload); 294 | $event = $this->buildEvent($eventPayload); 295 | 296 | if ($event !== null) { 297 | $this->send($event); 298 | } 299 | } 300 | 301 | /** 302 | * Prepare the event for sending by applying release information and optional modifications. 303 | */ 304 | public function sendEvent(array $payload): void 305 | { 306 | $payload['context'] = array_merge($this->context, $payload['context'] ?? []); 307 | $payload['user'] = $this->user; 308 | 309 | $eventPayload = $this->eventPayloadBuilder->create($payload); 310 | $event = $this->buildEvent($eventPayload); 311 | 312 | if ($event !== null) { 313 | $this->send($event); 314 | } 315 | } 316 | 317 | /** 318 | * Prepare the event for sending by applying release information and optional modifications. 319 | */ 320 | public function buildEvent(EventPayload $eventPayload): ?Event 321 | { 322 | $eventPayload->setRelease($this->options->getRelease()); 323 | $beforeSendCallback = $this->options->getBeforeSend(); 324 | 325 | if ($beforeSendCallback) { 326 | $eventPayload = $beforeSendCallback($eventPayload); 327 | if ($eventPayload === null) { 328 | return null; 329 | } 330 | } 331 | 332 | return new Event( 333 | $this->options->getIntegrationToken(), 334 | $eventPayload 335 | ); 336 | } 337 | 338 | /** 339 | * Determines if the error should be handled, considering its level and if it was silenced using (@). 340 | */ 341 | private function shouldHandleError(int $level, bool $silenced): bool 342 | { 343 | if ($silenced) { 344 | return $this->options->shouldCaptureSilencedErrors(); 345 | } 346 | 347 | return ($this->options->getErrorTypes() & $level) !== 0; 348 | } 349 | 350 | /** 351 | * Send the event to the remote server. 352 | */ 353 | private function send(Event $event): void 354 | { 355 | $this->transport->send($event); 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | 'integrationToken', 52 | 'integration_token' => 'integrationToken', 53 | 'url' => 'url', 54 | 'release' => 'release', 55 | 'errorTypes' => 'errorTypes', 56 | 'error_types' => 'errorTypes', 57 | 'captureSilencedErrors' => 'captureSilencedErrors', 58 | 'capture_silenced_errors' => 'captureSilencedErrors', 59 | 'beforeSend' => 'beforeSend', 60 | 'before_send' => 'beforeSend', 61 | 'timeout' => 'timeout', 62 | ]; 63 | 64 | /** 65 | * Options constructor. 66 | * 67 | * @param array $options Associative array of options to initialize. 68 | */ 69 | public function __construct(array $options = []) 70 | { 71 | foreach ($options as $key => $value) { 72 | $normalizedKey = self::OPTION_KEYS[$key] ?? null; 73 | 74 | if ($normalizedKey === null) { 75 | throw new \InvalidArgumentException("Unknown option: $key"); 76 | } 77 | 78 | $this->setOption($normalizedKey, $value); 79 | } 80 | } 81 | 82 | /** 83 | * Set a class property based on the normalized option key. 84 | * 85 | * @param string $key 86 | * @param mixed $value 87 | */ 88 | private function setOption(string $key, $value): void 89 | { 90 | switch ($key) { 91 | case 'integrationToken': 92 | case 'release': 93 | case 'url': 94 | if (!is_string($value)) { 95 | throw new \InvalidArgumentException("Option '$key' must be a string."); 96 | } 97 | $this->$key = $value; 98 | 99 | break; 100 | 101 | case 'errorTypes': 102 | if (!is_int($value) && $value !== null) { 103 | throw new \InvalidArgumentException("Option 'errorTypes' must be an integer or null."); 104 | } 105 | $this->errorTypes = $value; 106 | 107 | break; 108 | 109 | case 'captureSilencedErrors': 110 | if (!is_bool($value)) { 111 | throw new \InvalidArgumentException("Option 'captureSilencedErrors' must be a boolean."); 112 | } 113 | $this->captureSilencedErrors = $value; 114 | 115 | break; 116 | 117 | case 'beforeSend': 118 | if (!is_callable($value) && $value !== null) { 119 | throw new \InvalidArgumentException("Option 'beforeSend' must be callable or null."); 120 | } 121 | $this->beforeSend = $value; 122 | 123 | break; 124 | 125 | case 'timeout': 126 | if (!is_int($value)) { 127 | throw new \InvalidArgumentException("Option 'timeout' must be an integer."); 128 | } 129 | $this->timeout = $value; 130 | 131 | break; 132 | 133 | default: 134 | throw new \InvalidArgumentException("Unknown option '$key'."); 135 | } 136 | } 137 | 138 | public function getIntegrationToken(): string 139 | { 140 | return $this->integrationToken; 141 | } 142 | 143 | public function getUrl(): string 144 | { 145 | return $this->url; 146 | } 147 | 148 | public function getRelease(): string 149 | { 150 | return $this->release; 151 | } 152 | 153 | public function getErrorTypes(): int 154 | { 155 | return $this->errorTypes ?? error_reporting(); 156 | } 157 | 158 | public function shouldCaptureSilencedErrors(): bool 159 | { 160 | return $this->captureSilencedErrors; 161 | } 162 | 163 | public function getBeforeSend(): ?callable 164 | { 165 | return $this->beforeSend; 166 | } 167 | 168 | public function getTimeout(): int 169 | { 170 | return $this->timeout; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Serializer.php: -------------------------------------------------------------------------------- 1 | prepare($value)); 24 | 25 | if ($encoded === false) { 26 | return ''; 27 | } 28 | 29 | return $encoded; 30 | } 31 | 32 | /** 33 | * Prepares value for encoding 34 | * 35 | * @param $value 36 | * 37 | * @return array|mixed|string 38 | */ 39 | private function prepare($value) 40 | { 41 | if (!is_object($value) && (is_array($value) || is_iterable($value))) { 42 | $result = []; 43 | foreach ($value as $key => $subValue) { 44 | if (is_array($subValue) || is_iterable($subValue)) { 45 | $result[$key] = $this->prepare($subValue); 46 | } else { 47 | $result[$key] = $this->transform($subValue); 48 | } 49 | } 50 | 51 | return $result; 52 | } else { 53 | return $this->transform($value); 54 | } 55 | } 56 | 57 | /** 58 | * Transforms value to string or returns itself 59 | * 60 | * @param $value 61 | * 62 | * @return mixed|string 63 | */ 64 | private function transform($value) 65 | { 66 | if (is_null($value)) { 67 | return 'null'; 68 | } elseif (is_callable($value)) { 69 | return 'Closure'; 70 | } elseif (is_object($value)) { 71 | return get_class($value); 72 | } elseif (is_resource($value)) { 73 | return 'Resource'; 74 | } else { 75 | return $value; 76 | } 77 | } 78 | 79 | /** 80 | * Check array if it is associative 81 | * 82 | * @param array $array 83 | * 84 | * @return bool 85 | */ 86 | private function isAssoc(array $array): bool 87 | { 88 | if ([] === $array) { 89 | return false; 90 | } 91 | 92 | return array_keys($array) !== range(0, count($array) - 1); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Severity.php: -------------------------------------------------------------------------------- 1 | validate($value); 28 | $this->value = $value; 29 | } 30 | 31 | /** 32 | * Translate a PHP Error constant into string. 33 | * 34 | * @param int $severity 35 | * 36 | * @return \Hawk\Severity 37 | */ 38 | public static function fromError(int $severity): self 39 | { 40 | $warnings = [ 41 | \E_DEPRECATED, 42 | \E_USER_DEPRECATED, 43 | \E_WARNING, 44 | \E_USER_WARNING, 45 | ]; 46 | 47 | if (in_array($severity, $warnings)) { 48 | return self::warning(); 49 | } 50 | 51 | $fatals = [ 52 | \E_ERROR, 53 | \E_PARSE, 54 | \E_CORE_ERROR, 55 | \E_CORE_WARNING, 56 | \E_COMPILE_ERROR, 57 | \E_COMPILE_WARNING, 58 | ]; 59 | 60 | if (in_array($severity, $fatals)) { 61 | return self::fatal(); 62 | } 63 | 64 | $errors = [ 65 | \E_RECOVERABLE_ERROR, 66 | \E_USER_ERROR, 67 | ]; 68 | 69 | if (in_array($severity, $errors)) { 70 | return self::error(); 71 | } 72 | 73 | $infos = [ 74 | \E_NOTICE, 75 | \E_USER_NOTICE, 76 | \E_STRICT, 77 | ]; 78 | 79 | if (in_array($severity, $infos)) { 80 | return self::info(); 81 | } 82 | 83 | return self::error(); 84 | } 85 | 86 | /** 87 | * Creates a new instance with "debug" value. 88 | */ 89 | public static function debug(): self 90 | { 91 | return new self(self::DEBUG); 92 | } 93 | 94 | /** 95 | * Creates a new instance with "info" value. 96 | */ 97 | public static function info(): self 98 | { 99 | return new self(self::INFO); 100 | } 101 | 102 | /** 103 | * Creates a new instance with "warning" value. 104 | */ 105 | public static function warning(): self 106 | { 107 | return new self(self::WARNING); 108 | } 109 | 110 | /** 111 | * Creates a new instance with "error" value. 112 | */ 113 | public static function error(): self 114 | { 115 | return new self(self::ERROR); 116 | } 117 | 118 | /** 119 | * Creates a new instance with "fatal" value. 120 | */ 121 | public static function fatal(): self 122 | { 123 | return new self(self::FATAL); 124 | } 125 | 126 | /** 127 | * Returns severity value as string 128 | * 129 | * @return string 130 | */ 131 | public function getValue(): string 132 | { 133 | return $this->value; 134 | } 135 | 136 | /** 137 | * @param int $value 138 | */ 139 | private function validate(string $value) 140 | { 141 | $validErrorTypes = [ 142 | self::DEBUG, 143 | self::ERROR, 144 | self::FATAL, 145 | self::WARNING, 146 | self::INFO 147 | ]; 148 | 149 | if (!\in_array($value, $validErrorTypes, true)) { 150 | throw new \InvalidArgumentException(sprintf('The "%s" is not a valid severity value.', $value)); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/StacktraceFrameBuilder.php: -------------------------------------------------------------------------------- 1 | serializer = $serializer; 30 | } 31 | 32 | /** 33 | * Build exception backtrace. 34 | * 35 | * If you call debug_backtrace of getTrace functions then may return many 36 | * useless calls of processing the error by your framework. We are going 37 | * to go by stack from the entry point until we find string with error. 38 | * Then we can throw away all unnecessary calls. 39 | * Not always string with error will be in stack. So if we find it, 40 | * we will throw it away too. We have enough information to add this last 41 | * call manually. 42 | * 43 | * @param Throwable $exception 44 | * 45 | * @return array 46 | */ 47 | public function buildStack(Throwable $exception): array 48 | { 49 | /** 50 | * Get trace to exception 51 | */ 52 | $stack = $exception->getTrace(); 53 | 54 | /** 55 | * Prepare new stack to be filled 56 | */ 57 | $newStack = []; 58 | 59 | /** 60 | * Frames iterator 61 | */ 62 | $i = 0; 63 | 64 | /** 65 | * Add real error's path to trace chain 66 | * 67 | * Stack does not contain the latest (real) event frame 68 | * so we use getFile() and getLine() exception's methods 69 | * to get data for sources. 70 | */ 71 | $newStack[$i] = [ 72 | 'file' => $exception->getFile(), 73 | 'line' => $exception->getLine(), 74 | 'sourceCode' => $this->getAdjacentLines($exception->getFile(), $exception->getLine()), 75 | ]; 76 | 77 | /** 78 | * This flag tells us that we have already found string with error 79 | * in stack and all following calls should be removed 80 | */ 81 | $isErrorPositionWasFound = false; 82 | 83 | /** 84 | * Go through the stack 85 | */ 86 | foreach ($stack as $index => $callee) { 87 | 88 | /** 89 | * Ignore callee if we don't khow it's filename 90 | */ 91 | $isCalleeHasNoFile = empty($callee['file']); 92 | 93 | /** 94 | * Add ignore rules 95 | * - check if filepath is empty 96 | * - check if we have found real error in stack 97 | */ 98 | if ($isCalleeHasNoFile || $isErrorPositionWasFound) { 99 | /** 100 | * Remove this call 101 | */ 102 | unset($stack[$index]); 103 | 104 | continue; 105 | } 106 | 107 | /** 108 | * Is it our error? Check for a file and line similarity 109 | */ 110 | if ($exception->getFile() == $callee['file'] && $exception->getLine() == $callee['line']) { 111 | /** 112 | * We have found error in stack 113 | * Then we can ignore all other calls 114 | */ 115 | $isErrorPositionWasFound = true; 116 | 117 | /** 118 | * Remove this call 119 | * We will add it here manually later 120 | */ 121 | unset($stack[$index]); 122 | 123 | continue; 124 | } 125 | 126 | $frame = $stack[$index]; 127 | 128 | /** 129 | * Compose new frame 130 | */ 131 | $newStack[++$i] = [ 132 | 'file' => $frame['file'], 133 | 'line' => $frame['line'], 134 | 'sourceCode' => $this->getAdjacentLines($frame['file'], $frame['line']), 135 | ]; 136 | 137 | /** 138 | * Fill function and arguments data for the previous frame 139 | * 140 | * Each stack's frame contains data about the called method 141 | * and it's arguments but this data is useful only for 142 | * the previous frame because we get source code line 143 | * for that method. 144 | * 145 | * For the oldest frame (the last frame in the stack) we have 146 | * no method (and arguments) because it is an entry point 147 | * for the script. Then these fields for the last stack 148 | * frame $i will be empty. 149 | */ 150 | $newStack[$i - 1]['function'] = $this->composeFunctionName($frame); 151 | $newStack[$i - 1]['arguments'] = $this->getArgs($frame); 152 | } 153 | 154 | return $newStack; 155 | } 156 | 157 | /** 158 | * Compose function name with a class for frame 159 | * 160 | * @param array $frame - backtrace frame 161 | * 162 | * @return string 163 | */ 164 | private function composeFunctionName(array $frame): string 165 | { 166 | /** 167 | * Set an empty function name to be returned 168 | */ 169 | $functionName = ''; 170 | 171 | /** 172 | * Fill name with a class name and type '::' or '->' 173 | */ 174 | if (!empty($frame['class'])) { 175 | $functionName = $frame['class'] . $frame['type']; 176 | } 177 | 178 | /** 179 | * Add a real function name 180 | */ 181 | $functionName .= $frame['function']; 182 | 183 | return $functionName; 184 | } 185 | 186 | /** 187 | * Get function arguments for a frame 188 | * 189 | * @param array $frame - backtrace frame 190 | * 191 | * @return array 192 | */ 193 | private function getArgs(array $frame): array 194 | { 195 | /** 196 | * Defining an array of arguments to be returned 197 | */ 198 | $arguments = []; 199 | 200 | /** 201 | * If args param is not exist or empty 202 | * then return empty args array 203 | */ 204 | if (empty($frame['args'])) { 205 | return $arguments; 206 | } 207 | 208 | /** 209 | * ReflectionFunction/ReflectionMethod class reports information 210 | * about a function/method. 211 | */ 212 | $reflection = $this->getReflectionMethod($frame); 213 | 214 | /** 215 | * If reflection function in missing then create a simple list of arguments 216 | */ 217 | if (!$reflection) { 218 | foreach ($frame['args'] as $index => $value) { 219 | $arguments['arg' . $index] = $value; 220 | } 221 | } else { 222 | /** 223 | * Get reflection params 224 | */ 225 | $reflectionParams = $reflection->getParameters(); 226 | 227 | /** 228 | * Passing through reflection params to get real names for values 229 | */ 230 | foreach ($reflectionParams as $reflectionParam) { 231 | $paramName = $reflectionParam->getName(); 232 | $paramPosition = $reflectionParam->getPosition(); 233 | 234 | if (isset($frame['args'][$paramPosition])) { 235 | $arguments[$paramName] = $frame['args'][$paramPosition]; 236 | } 237 | } 238 | } 239 | 240 | /** 241 | * @todo Remove the following code when hawk.types 242 | * supports non-iterable list of arguments 243 | */ 244 | $newArguments = []; 245 | foreach ($arguments as $name => $value) { 246 | $value = $this->serializer->serializeValue($value); 247 | 248 | try { 249 | $newArguments[] = sprintf('%s = %s', $name, $value); 250 | } catch (\Exception $e) { 251 | // Ignore unknown types 252 | } 253 | } 254 | 255 | $arguments = $newArguments; 256 | 257 | return $arguments; 258 | } 259 | 260 | /** 261 | * Trying to create a reflection method 262 | * 263 | * @param array $frame - backtrace frame 264 | * 265 | * @return \ReflectionFunction|\ReflectionMethod|null 266 | */ 267 | private function getReflectionMethod(array $frame): ?ReflectionFunctionAbstract 268 | { 269 | /** 270 | * Trying to create a correct reflection 271 | */ 272 | try { 273 | /** 274 | * If we know class and method 275 | */ 276 | if (!empty($frame['class']) && !empty($frame['function'])) { 277 | return new \ReflectionMethod($frame['class'], $frame['function']); 278 | } 279 | 280 | /** 281 | * If class name is missing then create a non-class function 282 | */ 283 | if (empty($frame['class'])) { 284 | return new \ReflectionFunction($frame['function']); 285 | } 286 | } catch (\ReflectionException $e) { 287 | // Cannot create a reflection 288 | } 289 | 290 | /** 291 | * Return null if we cannot create a reflection 292 | */ 293 | return null; 294 | } 295 | 296 | /** 297 | * Get path of file near target line to return as array 298 | * 299 | * @param string $filepath path to source file 300 | * @param int $line number of the target line 301 | * @param int $margin max number of lines before and after target line 302 | * to be returned 303 | * 304 | * @return array 305 | */ 306 | private function getAdjacentLines(string $filepath, int $line, int $margin = 5): array 307 | { 308 | if (!file_exists($filepath) || !is_readable($filepath)) { 309 | return []; 310 | } 311 | 312 | /** 313 | * Get file as array of lines 314 | */ 315 | $fileLines = file($filepath); 316 | 317 | /** 318 | * In the file lines are counted from 1 but in array first element 319 | * is on 0 position. So to get line position in array 320 | * we need to decrease real line by 1 321 | */ 322 | $errorLineInArray = $line - 1; 323 | 324 | /** 325 | * Get upper and lower lines positions to return part of file 326 | */ 327 | $firstLine = $errorLineInArray - $margin; 328 | $lastLine = $errorLineInArray + $margin; 329 | 330 | /** 331 | * Create an empty array to be returned 332 | */ 333 | $nearErrorFileLines = []; 334 | 335 | /** 336 | * Read file from $firstLine to $lastLine by lines 337 | */ 338 | for ($line = $firstLine; $line <= $lastLine; $line++) { 339 | /** 340 | * Check if line doesn't exist. For elements positions in array before 0 341 | * and after end of file will be returned NULL 342 | */ 343 | if (!empty($fileLines[$line])) { 344 | $lineContent = $fileLines[$line]; 345 | 346 | /** 347 | * Remove line breaks 348 | */ 349 | $lineContent = preg_replace("/\r|\n/", '', $lineContent); 350 | 351 | /** 352 | * Add new line 353 | */ 354 | $nearErrorFileLines[] = [ 355 | /** 356 | * Save real line 357 | */ 358 | 'line' => $line + 1, 359 | 'content' => $lineContent 360 | ]; 361 | } 362 | } 363 | 364 | return $nearErrorFileLines; 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /src/Transport/CurlTransport.php: -------------------------------------------------------------------------------- 1 | url = $url; 38 | $this->timeout = $timeout; 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public function getUrl(): string 45 | { 46 | return $this->url; 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function send(Event $event) 53 | { 54 | /** 55 | * If php-curl is not available then throw an exception 56 | */ 57 | if (!extension_loaded('curl')) { 58 | throw new \Exception('The cURL PHP extension is required to use the Hawk PHP Catcher'); 59 | } 60 | 61 | $curl = curl_init(); 62 | curl_setopt($curl, CURLOPT_URL, $this->url); 63 | curl_setopt($curl, CURLOPT_POST, true); 64 | curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($event, JSON_UNESCAPED_UNICODE)); 65 | curl_setopt($curl, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); 66 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 67 | curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeout); 68 | $response = curl_exec($curl); 69 | curl_close($curl); 70 | 71 | return $response; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Transport/GuzzleTransport.php: -------------------------------------------------------------------------------- 1 | url = $url; 21 | } 22 | 23 | /** 24 | * @inheritDoc 25 | */ 26 | public function getUrl(): string 27 | { 28 | return $this->url; 29 | } 30 | 31 | /** 32 | * @inheritDoc 33 | */ 34 | public function send(Event $event): void 35 | { 36 | // TODO: Implement send() method. 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Transport/TransportInterface.php: -------------------------------------------------------------------------------- 1 | 1, 24 | 'name' => 'Tester' 25 | ]; 26 | 27 | $serializer = new Serializer(); 28 | $stacktraceFrameBuilder = new StacktraceFrameBuilder($serializer); 29 | $eventPayloadBuilder = new EventPayloadBuilder($stacktraceFrameBuilder); 30 | $payload = $eventPayloadBuilder->create([ 31 | 'context' => $context, 32 | 'user' => $user, 33 | ]); 34 | 35 | $this->assertInstanceOf(EventPayload::class, $payload); 36 | $this->assertSame($user, $payload->getUser()); 37 | $this->assertSame($context, $payload->getContext()); 38 | } 39 | 40 | public function testCreationWithCustomException(): void 41 | { 42 | $exception = new \Exception('exception message'); 43 | 44 | $serializer = new Serializer(); 45 | $stacktraceFrameBuilder = new StacktraceFrameBuilder($serializer); 46 | 47 | $eventPayloadBuilder = new EventPayloadBuilder($stacktraceFrameBuilder); 48 | $payload = $eventPayloadBuilder->create([ 49 | 'context' => [], 50 | 'user' => [], 51 | 'exception' => $exception, 52 | 'type' => 1 53 | ]); 54 | 55 | $this->assertInstanceOf(EventPayload::class, $payload); 56 | $this->assertEmpty($payload->getContext()); 57 | $this->assertEmpty($payload->getUser()); 58 | $this->assertEquals($exception->getMessage(), $payload->getTitle()); 59 | $this->assertEquals($payload->getType(), Severity::fatal()); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Unit/OptionsTest.php: -------------------------------------------------------------------------------- 1 | assertNull($options->getBeforeSend()); 17 | $this->assertEmpty($options->getIntegrationToken()); 18 | $this->assertEmpty($options->getRelease()); 19 | $this->assertEquals('https://k1.hawk.so/', $options->getUrl()); 20 | $this->assertEquals(error_reporting(), $options->getErrorTypes()); 21 | } 22 | 23 | public function testCustomOptions(): void 24 | { 25 | $config = [ 26 | 'url' => 'www.mysite.com', 27 | 'integrationToken' => 'myToken', 28 | 'release' => '123', 29 | 'error_types' => 11, 30 | 'beforeSend' => function () { 31 | } 32 | ]; 33 | 34 | $options = new Options($config); 35 | $this->assertSame($config, [ 36 | 'url' => $options->getUrl(), 37 | 'integrationToken' => $options->getIntegrationToken(), 38 | 'release' => $options->getRelease(), 39 | 'error_types' => $options->getErrorTypes(), 40 | 'beforeSend' => $options->getBeforeSend() 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Unit/SerializerTest.php: -------------------------------------------------------------------------------- 1 | serializeValue($testCase['value']); 21 | 22 | $this->assertEquals($testCase['expect'], $result); 23 | } 24 | 25 | public function testSerializationWithMediumSizeArray(): void 26 | { 27 | $mediumArray = []; 28 | $this->fillArray($mediumArray, 1, 20); 29 | 30 | $fixture = new Serializer(); 31 | $result = $fixture->serializeValue($mediumArray); 32 | 33 | $this->assertEquals('{"1":{"2":{"3":{"4":{"5":{"6":{"7":{"8":{"9":{"10":{"11":{"12":{"13":{"14":{"15":{"16":{"17":{"18":{"19":[]}}}}}}}}}}}}}}}}}}}', $result); 34 | } 35 | 36 | public function testSerializationWithLargeArray(): void 37 | { 38 | // MaxDepth is 1000 39 | $largeArray = []; 40 | $this->fillArray($largeArray); 41 | 42 | $fixture = new Serializer(); 43 | 44 | // json_encode will return false and result is empty string 45 | $result = $fixture->serializeValue($largeArray); 46 | 47 | $this->assertEquals('', trim($result)); 48 | } 49 | 50 | /** 51 | * Fills empty array with values: 52 | * [1 => [2 => [3 => ....]]] 53 | * 54 | * @param array $array 55 | * @param int $currentDepth 56 | * @param int $maxDepth 57 | */ 58 | private function fillArray(array &$array, int $currentDepth = 0, int $maxDepth = 1000) 59 | { 60 | if ($currentDepth === $maxDepth) { 61 | return; 62 | } 63 | 64 | $array[$currentDepth] = []; 65 | $this->fillArray($array[$currentDepth], $currentDepth + 1, $maxDepth); 66 | } 67 | 68 | /** 69 | * Returns list of test cases 70 | * 71 | * @return array 72 | */ 73 | public function valueProvider(): array 74 | { 75 | return [ 76 | [ 77 | [ 78 | 'value' => 9999, 79 | 'expect' => '9999' 80 | ] 81 | ], 82 | [ 83 | [ 84 | 'value' => true, 85 | 'expect' => 'true' 86 | ] 87 | ], 88 | [ 89 | [ 90 | 'value' => 'val', 91 | 'expect' => '"val"' 92 | ] 93 | ], 94 | [ 95 | [ 96 | 'value' => [1, 2, 3, 4, 5], 97 | 'expect' => '[1,2,3,4,5]' 98 | ] 99 | ], 100 | [ 101 | [ 102 | 'value' => ['string', 1, true], 103 | 'expect' => '["string",1,true]' 104 | ] 105 | ], 106 | [ 107 | [ 108 | 'value' => [function () { 109 | }, \Closure::class], 110 | 'expect' => '["Closure","Closure"]' 111 | ] 112 | ], 113 | [ 114 | [ 115 | 'value' => new \stdClass(), 116 | 'expect' => '"stdClass"' 117 | ] 118 | ], 119 | [ 120 | [ 121 | 'value' => [1, new \stdClass(), new \Exception()], 122 | 'expect' => '[1,"stdClass","Exception"]' 123 | ] 124 | ], 125 | [ 126 | [ 127 | 'value' => [[1, 2, 3], 'something', [function () { 128 | }, [new \stdClass()]]], 129 | 'expect' => '[[1,2,3],"something",["Closure",["stdClass"]]]' 130 | ] 131 | ], 132 | [ 133 | [ 134 | 'value' => null, 135 | 'expect' => '"null"' 136 | ] 137 | ], 138 | [ 139 | [ 140 | 'value' => [new \ArrayIterator([1, 2, 3])], 141 | 'expect' => '["ArrayIterator"]' 142 | ] 143 | ], 144 | [ 145 | [ 146 | 'value' => new \CachingIterator(new \ArrayIterator()), 147 | 'expect' => '"CachingIterator"' 148 | ] 149 | ], 150 | [ 151 | [ 152 | 'value' => ['key1' => 'value1', 'key2' => 'value2'], 153 | 'expect' => '{"key1":"value1","key2":"value2"}' 154 | ] 155 | ], 156 | [ 157 | [ 158 | 'value' => urldecode('bad utf string %C4_'), 159 | 'expect' => '' 160 | ] 161 | ], 162 | ]; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /tests/Unit/StacktraceFrameBuilderTest.php: -------------------------------------------------------------------------------- 1 | new \Exception(), 20 | 'stackSize' => 12 21 | ]; 22 | 23 | $stacktrace = $fixture->buildStack($testCase['exception']); 24 | $this->assertCount($testCase['stackSize'], $stacktrace); 25 | } 26 | } 27 | --------------------------------------------------------------------------------