From 715f8bb96d7bab5e9d91cc234761aed6f9eb5c51 Mon Sep 17 00:00:00 2001 From: Benoit GALATI Date: Sat, 17 Aug 2019 11:25:36 +0200 Subject: [PATCH] Add SentryHandler (#1) --- .editorconfig | 15 ++ .gitattributes | 14 + .gitignore | 4 + .php_cs.dist | 70 +++++ .travis.yml | 30 +++ CHANGELOG.md | 11 + CONTRIBUTING.md | 0 Makefile | 38 +++ composer.json | 39 +++ phpstan.neon.dist | 7 + phpunit.xml.dist | 22 ++ src/SentryHandler.php | 195 ++++++++++++++ tests/SentryHandlerTest.php | 515 ++++++++++++++++++++++++++++++++++++ 13 files changed, 960 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .php_cs.dist create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 composer.json create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/SentryHandler.php create mode 100644 tests/SentryHandlerTest.php 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'); + } +}