diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9f80a14 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = LF +insert_final_newline = true +charset = utf-8 +indent_size = 4 +indent_style = space +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00c1492 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +Makefile export-ignore +README.md export-ignore +tests/ export-ignore +.php_cs.dist export-ignore +.phpstan.neon.dist export-ignore +doc/ export-ignore +.php_cs.dist export-ignore +.editorconfig export-ignore +CHANGELOG.md export-ignore +CONTRIBUTING.md export-ignore +phpunit.xml.dist export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c04c07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/composer.lock +/vendor/ +.php_cs.cache +.phpunit.result.cache diff --git a/.php_cs.dist b/.php_cs.dist new file mode 100644 index 0000000..965f3ae --- /dev/null +++ b/.php_cs.dist @@ -0,0 +1,70 @@ +in(__DIR__.'/src') + ->in(__DIR__.'/tests') + ->append([__FILE__]) +; + +return PhpCsFixer\Config::create() + ->setRules([ + '@DoctrineAnnotation' => true, + '@PHP71Migration' => true, + '@PHP71Migration:risky' => true, + '@PHPUnit60Migration:risky' => true, + '@Symfony' => true, + '@Symfony:risky' => true, + 'void_return' => false, // Enforce by @PHP71Migration:risky but can break method definition for libraries + 'align_multiline_comment' => ['comment_type' => 'phpdocs_only'], + 'array_indentation' => true, + 'braces' => ['allow_single_line_closure' => true], + 'compact_nullable_typehint' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'break', + 'continue', + 'curly_brace_block', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'throw', + 'use', + ], + ], + 'no_useless_else' => true, + 'no_useless_return' => true, + 'ordered_imports' => [ + 'importsOrder' => [ + 'class', + 'function', + 'const', + ], + 'sortAlgorithm' => 'alpha', + ], + 'php_unit_method_casing' => [ + 'case' => 'camel_case', + ], + 'phpdoc_order' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'strict_comparison' => true, + 'strict_param' => true, + 'phpdoc_summary' => false, + 'no_unneeded_final_method' => false, + 'no_superfluous_phpdoc_tags' => true, + 'concat_space' => ['spacing' => 'none'], + 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], + 'phpdoc_to_comment' => false, + 'native_constant_invocation' => true, + 'native_function_invocation' => ['include' => ['@compiler_optimized']], + 'array_syntax' => ['syntax' => 'short'], + 'declare_strict_types' => true, + 'no_whitespace_before_comma_in_array' => false, + 'binary_operator_spaces' => ['default' => 'align'], + 'self_accessor' => false, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder) +; diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5b16116 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,30 @@ +language: php +dist: bionic +sudo: false + +env: + global: + - COMPOSER_INSTALL_FLAGS="--no-interaction --no-progress --prefer-dist" + - COMPOSER_UPDATE_FLAGS="--no-interaction --no-progress --prefer-dist" + +matrix: + fast_finish: true + include: + - php: 7.1 + env: COMPOSER_UPDATE_FLAGS="--prefer-lowest ${COMPOSER_UPDATE_FLAGS}" + - php: 7.2 + - php: 7.3 + +cache: + directories: + - ${HOME}/.composer/cache + +before_install: + - phpenv config-rm xdebug.ini || true + +before_script: + - composer self-update + +script: + - stty cols 120 + - make tests-ci diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d537409 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased](https://github.com/B-Galati/monolog-sentry-handler/compare/v1.0.0...HEAD) + +## [1.0.0](https://github.com/B-Galati/monolog-sentry-handler/compare/acf546c...v1.0.0) - 2019-08-17 +### Added +- First version of `SentryHandler` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e69de29 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..caadc53 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +COMPOSER_INSTALL_FLAGS ?= +COMPOSER_UPDATE_FLAGS ?= + +.DEFAULT_GOAL := help +.PHONY: help +help: + @grep -E '(^[a-zA-Z_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/' + +vendor: composer.lock composer.json ## install composer deps + composer install $(COMPOSER_INSTALL_FLAGS) + @touch $@ +composer.lock: + composer update $(COMPOSER_UPDATE_FLAGS) + @touch $@ + +.PHONY: tests-ci tests +tests-ci: composer-validate phpstan cs-check phpunit +tests: tests-ci cs-check + +.PHONY: composer-validate +composer-validate: vendor composer.json composer.lock ## Validate composer.json file + composer validate + +.PHONY: phpstan +phpstan: vendor ## Check PHP code style + vendor/bin/phpstan analyse -l7 -- src tests + +.PHONY: phpunit +phpunit: vendor ## Run PhpUnit tests + vendor/bin/phpunit -v --testdox + +.PHONY: php-cs-fixer-check +cs-check: vendor ## Check php code style + vendor/bin/php-cs-fixer fix --diff --dry-run --no-interaction -v --cache-file=.php_cs.cache --stop-on-violation + +.PHONY: vendor cs-fix +cs-fix: ## Automatically php code style + vendor/bin/php-cs-fixer fix -v --cache-file=.php_cs.cache diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..dedce7c --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "bgalati/sentry-handler", + "description": "Sentry handler for php SDK", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Benoit Galati", + "email": "benoit.galati@gmail.com" + } + ], + "autoload": { + "psr-4": { + "BGalati\\MonologSentryHandler\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "BGalati\\MonologSentryHandler\\Tests\\": "tests" + } + }, + "config": { + "sort-packages": true + }, + "require": { + "php": "^7.1.3", + "monolog/monolog": "^1.6", + "sentry/sentry": "^2.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.15", + "jangregor/phpstan-prophecy": "^0.4.2", + "phpstan/phpstan": "^0.11.13", + "phpstan/phpstan-phpunit": "^0.11.2", + "phpunit/phpunit": "^7.5||^8.3", + "sentry/sdk": "^2.0", + "symfony/var-dumper": ">4.3" + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..080c634 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon + - vendor/jangregor/phpstan-prophecy/src/extension.neon +parameters: + inferPrivatePropertyTypeFromConstructor: true + tipsOfTheDay: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..73978aa --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + diff --git a/src/SentryHandler.php b/src/SentryHandler.php new file mode 100644 index 0000000..82383e8 --- /dev/null +++ b/src/SentryHandler.php @@ -0,0 +1,195 @@ +hub = $hub; + } + + /** + * {@inheritdoc} + */ + public function handleBatch(array $records): void + { + if (!$records) { + return; + } + + // filter records + $records = array_filter( + $records, + function ($record) { + // Keep record that matches the minimum level + return $record['level'] >= $this->level; + } + ); + + // the record with the highest severity is the "main" one + $main = array_reduce( + $records, + static function ($highest, $record) { + if ($record['level'] > $highest['level']) { + return $record; + } + + return $highest; + } + ); + + // the other ones are added as a context items + foreach ($records as $record) { + $record = $this->processRecord($record); + $record['formatted'] = $this->getFormatter()->format($record); + + $this->breadcrumbsBuffer[] = $record; + } + + $this->handle($main); + + $this->breadcrumbsBuffer = []; + } + + /** + * {@inheritdoc} + */ + protected function write(array $record): void + { + $payload = [ + 'level' => $sentryLevel = $this->getSeverityFromLevel($record['level']), + 'message' => (new LineFormatter('%channel%.%level_name%: %message%'))->format($record), + ]; + + if (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) { + $payload['exception'] = $record['context']['exception']; + } + + $this->hub->withScope(function (Scope $scope) use ($record, $payload, $sentryLevel): void { + $scope->setLevel($sentryLevel); + $scope->setExtra('monolog.formatted', $record['formatted'] ?? ''); + + foreach ($this->breadcrumbsBuffer as $breadcrumbRecord) { + $scope->addBreadcrumb(new Breadcrumb( + $this->getBreadcrumbLevelFromLevel($breadcrumbRecord['level']), + $this->getBreadcrumbTypeFromLevel($breadcrumbRecord['level']), + $breadcrumbRecord['channel'] ?? 'N/A', + $breadcrumbRecord['formatted'] ?? 'N/A' + )); + } + + $this->hub->captureEvent($payload); + }); + + $this->flushSentryEvents(); + } + + /** + * Block until all async events are processed for the HTTP transport. + * + * @see https://github.com/getsentry/sentry-php/issues/811 + */ + private function flushSentryEvents(): void + { + // Inspired by https://github.com/getsentry/sentry-laravel/blob/14e8bf07f4254f031db3e88096ed8a8959aa34c1/src/Sentry/Laravel/Integration.php#L94-L112 + $client = $this->hub->getClient(); + + if (!$client instanceof Client) { + return; + } + + $transportProperty = new \ReflectionProperty(Client::class, 'transport'); + $transportProperty->setAccessible(true); + + $transport = $transportProperty->getValue($client); + + if ($transport instanceof HttpTransport) { + \Closure::bind( + function () {$this->cleanupPendingRequests(); }, + $transport, + $transport + )(); + } + } + + /** + * Translates the Monolog level into the Sentry severity. + * + * @param int $level The Monolog log level + */ + private function getSeverityFromLevel(int $level): Severity + { + switch ($level) { + case Logger::DEBUG: + return Severity::debug(); + case Logger::INFO: + case Logger::NOTICE: + return Severity::info(); + case Logger::WARNING: + return Severity::warning(); + case Logger::ERROR: + return Severity::error(); + default: + return Severity::fatal(); + } + } + + /** + * Translates the Monolog level into the Sentry breadcrumb level. + * + * @param int $level The Monolog log level + */ + private function getBreadcrumbLevelFromLevel(int $level): string + { + switch ($level) { + case Logger::DEBUG: + return Breadcrumb::LEVEL_DEBUG; + case Logger::INFO: + case Logger::NOTICE: + return Breadcrumb::LEVEL_INFO; + case Logger::WARNING: + return Breadcrumb::LEVEL_WARNING; + case Logger::ERROR: + return Breadcrumb::LEVEL_ERROR; + default: + return Breadcrumb::LEVEL_CRITICAL; + } + } + + /** + * Translates the Monolog level into the Sentry breadcrumb type. + * + * @param int $level The Monolog log level + */ + private function getBreadcrumbTypeFromLevel(int $level): string + { + if ($level >= Logger::ERROR) { + return Breadcrumb::TYPE_ERROR; + } + + return Breadcrumb::TYPE_DEFAULT; + } +} diff --git a/tests/SentryHandlerTest.php b/tests/SentryHandlerTest.php new file mode 100644 index 0000000..bf686f0 --- /dev/null +++ b/tests/SentryHandlerTest.php @@ -0,0 +1,515 @@ +prophesize(ClientInterface::class); + + $this->hub = new SpyHub(new Hub($client->reveal())); + } + + protected function tearDown(): void + { + $this->hub = null; + } + + public function testHandle(): void + { + $handler = $this->createSentryHandler(); + + $record = [ + 'message' => 'My info message', + 'context' => [], + 'level' => Logger::INFO, + 'level_name' => Logger::getLevelName(Logger::INFO), + 'channel' => 'app', + 'extra' => [], + ]; + + $handler->handle($record); + + $this->assertCapturedEvent( + Severity::info(), + 'app.INFO: My info message', + ['monolog.formatted' => 'app.INFO: My info message []'] + ); + } + + public function testHandleCaptureException(): void + { + $handler = $this->createSentryHandler(); + + $record = [ + 'message' => 'My info message', + 'context' => ['exception' => $exception = new \LogicException('Test logic exception')], + 'level' => Logger::INFO, + 'level_name' => Logger::getLevelName(Logger::INFO), + 'channel' => 'app', + 'extra' => [], + ]; + + $handler->handle($record); + + $this->assertCapturedEvent( + Severity::info(), + 'app.INFO: My info message', + ['monolog.formatted' => 'app.INFO: My info message []'], + $exception + ); + } + + public function testHandleBatchDoesNotCallSentryIfNoRecordsAreProvided(): void + { + $handler = $this->createSentryHandler(); + $handler->handleBatch([]); + + $this->assertNull($this->hub->spiedScope); + $this->assertNull($this->hub->spiedEvent); + } + + public function testHandleBatch(): void + { + $handler = $this->createSentryHandler(); + + $records = [ + [ + 'message' => 'Info message', + 'context' => ['exception' => new \LogicException()], + 'level' => $level = Logger::INFO, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-info', + 'extra' => ['extra-info'], + ], + [ + 'message' => 'Error Message', + 'context' => [], + 'level' => $level = Logger::ERROR, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-error', + 'extra' => ['extra-error'], + ], + [ + 'message' => 'Debug message', + 'context' => [], + 'level' => $level = Logger::DEBUG, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-debug', + 'extra' => ['extra-debug'], + ], + [ + 'message' => 'Emergency message', + 'context' => [], + 'level' => $level = Logger::EMERGENCY, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-emerg', + 'extra' => ['extra-emerg'], + ], + [ + 'message' => 'Warning message', + 'context' => [], + 'level' => $level = Logger::WARNING, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-warn', + 'extra' => ['extra-warn'], + ], + [ + 'message' => 'Notice message', + 'context' => [], + 'level' => $level = Logger::NOTICE, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-notice', + 'extra' => ['extra-notice'], + ], + [ + 'message' => 'Alert message', + 'context' => [], + 'level' => $level = Logger::ALERT, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-alert', + 'extra' => ['extra-alert'], + ], + [ + 'message' => 'Critical message', + 'context' => ['exception' => new \LogicException()], + 'level' => $level = Logger::CRITICAL, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'chan-critical', + 'extra' => ['extra-critical'], + ], + ]; + + $handler->handleBatch($records); + + $this->assertCapturedEvent( + Severity::fatal(), + 'chan-emerg.EMERGENCY: Emergency message', + ['monolog.formatted' => 'chan-emerg.EMERGENCY: Emergency message ["extra-emerg"]'], + null, + [ + [ + 'type' => 'default', + 'category' => 'chan-info', + 'level' => 'info', + 'message' => 'chan-info.INFO: Info message ["extra-info"]', + 'data' => [], + ], + [ + 'type' => 'error', + 'category' => 'chan-error', + 'level' => 'error', + 'message' => 'chan-error.ERROR: Error Message ["extra-error"]', + 'data' => [], + ], + [ + 'type' => 'default', + 'category' => 'chan-debug', + 'level' => 'debug', + 'message' => 'chan-debug.DEBUG: Debug message ["extra-debug"]', + 'data' => [], + ], + [ + 'type' => 'error', + 'category' => 'chan-emerg', + 'level' => 'critical', + 'message' => 'chan-emerg.EMERGENCY: Emergency message ["extra-emerg"]', + 'data' => [], + ], + [ + 'type' => 'default', + 'category' => 'chan-warn', + 'level' => 'warning', + 'message' => 'chan-warn.WARNING: Warning message ["extra-warn"]', + 'data' => [], + ], + [ + 'type' => 'default', + 'category' => 'chan-notice', + 'level' => 'info', + 'message' => 'chan-notice.NOTICE: Notice message ["extra-notice"]', + 'data' => [], + ], + [ + 'type' => 'error', + 'category' => 'chan-alert', + 'level' => 'critical', + 'message' => 'chan-alert.ALERT: Alert message ["extra-alert"]', + 'data' => [], + ], + [ + 'type' => 'error', + 'category' => 'chan-critical', + 'level' => 'critical', + 'message' => 'chan-critical.CRITICAL: Critical message ["extra-critical"]', + 'data' => [], + ], + ] + ); + } + + public function testHandleBatchFiltersRecordsByLevel(): void + { + $handler = $this->createSentryHandler(Logger::WARNING); + + $records = [ + [ + 'message' => 'Info message', + 'context' => ['exception' => new \LogicException()], + 'level' => $level = Logger::INFO, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'test', + 'extra' => [], + ], + [ + 'message' => 'Error Message', + 'context' => [], + 'level' => $level = Logger::ERROR, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'test', + 'extra' => [], + ], + [ + 'message' => 'Debug message', + 'context' => [], + 'level' => $level = Logger::DEBUG, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'test', + 'extra' => [], + ], + [ + 'message' => 'Warning message', + 'context' => [], + 'level' => $level = Logger::WARNING, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'test', + 'extra' => [], + ], + [ + 'message' => 'Notice message', + 'context' => [], + 'level' => $level = Logger::NOTICE, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'test', + 'extra' => [], + ], + [ + 'message' => 'Critical message', + 'context' => ['exception' => $exception = new \LogicException('Exception of critical level')], + 'level' => $level = Logger::CRITICAL, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'test', + 'extra' => [], + ], + ]; + + $handler->handleBatch($records); + + $this->assertCapturedEvent( + Severity::fatal(), + 'test.CRITICAL: Critical message', + ['monolog.formatted' => 'test.CRITICAL: Critical message []'], + $exception, + [ + [ + 'type' => 'error', + 'category' => 'test', + 'level' => 'error', + 'message' => 'test.ERROR: Error Message []', + 'data' => [], + ], + [ + 'type' => 'default', + 'category' => 'test', + 'level' => 'warning', + 'message' => 'test.WARNING: Warning message []', + 'data' => [], + ], + [ + 'type' => 'error', + 'category' => 'test', + 'level' => 'critical', + 'message' => 'test.CRITICAL: Critical message []', + 'data' => [], + ], + ] + ); + } + + public function testHandleBatchCanBeCalledTwiceWithoutSideEffects(): void + { + $handler = $this->createSentryHandler(); + + $records = [ + [ + 'message' => 'Info message', + 'context' => [], + 'level' => $level = Logger::INFO, + 'level_name' => Logger::getLevelName($level), + 'channel' => 'test', + 'extra' => [], + ], + ]; + + $handler->handleBatch($records); + $this->hub->resetSpy(); + $handler->handleBatch($records); + + $this->assertCapturedEvent( + Severity::info(), + 'test.INFO: Info message', + ['monolog.formatted' => 'test.INFO: Info message []'], + null, + [ + [ + 'type' => 'default', + 'category' => 'test', + 'level' => 'info', + 'message' => 'test.INFO: Info message []', + 'data' => [], + ], + ] + ); + } + + private function assertCapturedEvent(Severity $severity, string $message, array $extra, \Exception $exception = null, array $breadcrumbs = []): void + { + $expectedEvent = [ + 'level' => $severity, + 'message' => $message, + ]; + + if (null !== $exception) { + $expectedEvent['exception'] = $exception; + } + + $this->assertEquals($expectedEvent, $this->hub->spiedEvent); + $this->assertSame($breadcrumbs, $this->hub->getSpiedScopeBreadcrumbsAsArray()); + $this->assertSame($extra, $this->hub->spiedScope->getExtra()); + $this->assertEquals($severity, $this->hub->spiedScope->getLevel()); + $this->assertSame([], $this->hub->spiedScope->getTags()); + $this->assertSame([], $this->hub->spiedScope->getUser()); + } + + private function createSentryHandler(int $level = null): SentryHandler + { + if (null === $level) { + $handler = new SentryHandler($this->hub); + } else { + $handler = new SentryHandler($this->hub, $level); + } + + $handler->setFormatter(new LineFormatter('%channel%.%level_name%: %message% %extra%')); + + return $handler; + } +} + +class SpyHub implements HubInterface +{ + private $hub; + + /** + * @var array|null + */ + public $spiedEvent; + + /** + * @var Scope|null + */ + public $spiedScope; + + public function __construct(Hub $hub) + { + $this->hub = $hub; + } + + public function resetSpy(): void + { + $this->spiedEvent = null; + $this->spiedScope = null; + } + + public function getSpiedScopeBreadcrumbsAsArray(): array + { + if (null === $this->spiedScope) { + throw new \RuntimeException('No spied scope'); + } + + return array_map( + function (Breadcrumb $breadcrumb) { + $array = $breadcrumb->toArray(); + + unset($array['timestamp']); + + return $array; + }, + $this->spiedScope->getBreadcrumbs() + ); + } + + public function getClient(): ?ClientInterface + { + return $this->hub->getClient(); + } + + public function getLastEventId(): ?string + { + throw new \RuntimeException('Not needed for test'); + } + + public function pushScope(): Scope + { + throw new \RuntimeException('Not needed for test'); + } + + public function popScope(): bool + { + throw new \RuntimeException('Not needed for test'); + } + + public function withScope(callable $callback): void + { + if (null !== $this->spiedScope) { + throw new \RuntimeException('There is already a scope registered in spy'); + } + + $this->hub->withScope(function (Scope $scope) use ($callback) { + $callback($scope); + $this->spiedScope = $scope; + }); + } + + public function configureScope(callable $callback): void + { + throw new \RuntimeException('Not needed for test'); + } + + public function bindClient(ClientInterface $client): void + { + throw new \RuntimeException('Not needed for test'); + } + + public function captureMessage(string $message, ?Severity $level = null): ?string + { + throw new \RuntimeException('Not needed for test'); + } + + public function captureException(\Throwable $exception): ?string + { + throw new \RuntimeException('Not needed for test'); + } + + public function captureEvent(array $payload): ?string + { + if (null !== $this->spiedEvent) { + throw new \RuntimeException('There is already an event registered in spy'); + } + + $this->spiedEvent = $payload; + + return $this->hub->captureEvent($payload); + } + + public function captureLastError(): ?string + { + throw new \RuntimeException('Not needed for test'); + } + + public function addBreadcrumb(Breadcrumb $breadcrumb): bool + { + return $this->hub->addBreadcrumb($breadcrumb); + } + + public static function getCurrent(): HubInterface + { + throw new \RuntimeException('Not needed for test'); + } + + public static function setCurrent(HubInterface $hub): HubInterface + { + throw new \RuntimeException('Not needed for test'); + } + + public function getIntegration(string $className): ?IntegrationInterface + { + throw new \RuntimeException('Not needed for test'); + } +}