├── tests ├── .gitkeep ├── EventListener │ └── MessengerProfilerListenerTest.php ├── Bundle │ └── DependencyInjection │ │ └── ConfigurationTest.php └── Messenger │ └── ProfilerMiddlewareTest.php ├── .env ├── .gitignore ├── src ├── Bundle │ ├── Resources │ │ └── config │ │ │ ├── profiler_spx.yaml │ │ │ ├── profiler_tideways.yaml │ │ │ ├── services.yaml │ │ │ ├── profiler_datadog.yaml │ │ │ ├── messenger_middleware.yaml │ │ │ ├── listener_command.yaml │ │ │ ├── listener_messenger.yaml │ │ │ ├── profiler_symfony.yaml │ │ │ └── profiler_newrelic.yaml │ ├── SourceabilityInstrumentationBundle.php │ └── DependencyInjection │ │ ├── SourceabilityInstrumentationExtension.php │ │ └── Configuration.php ├── Profiler │ ├── ProfilerInterface.php │ ├── SpxProfiler.php │ ├── ProfilerChain.php │ ├── TidewaysProfiler.php │ ├── NewRelicProfiler.php │ ├── DatadogProfiler.php │ └── SymfonyProfiler.php ├── EventListener │ ├── CommandProfilerListener.php │ └── MessengerProfilerListener.php └── Messenger │ └── ProfilerMiddleware.php ├── .editorconfig ├── docker-compose.yml ├── Dockerfile ├── phpunit.xml ├── LICENSE ├── Makefile ├── phpstan.neon ├── .yamllint.yaml ├── ecs.php ├── .github └── workflows │ └── main.yml ├── composer.json └── README.md /tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DEV_PHP_VERSION=8.1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | .phpunit.cache 4 | -------------------------------------------------------------------------------- /src/Bundle/Resources/config/profiler_spx.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Sourceability\Instrumentation\Profiler\SpxProfiler: 3 | class: 'Sourceability\Instrumentation\Profiler\SpxProfiler' 4 | tags: 5 | - { name: 'sourceability_instrumentation.profiler' } 6 | -------------------------------------------------------------------------------- /src/Bundle/Resources/config/profiler_tideways.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | Sourceability\Instrumentation\Profiler\TidewaysProfiler: 3 | class: 'Sourceability\Instrumentation\Profiler\TidewaysProfiler' 4 | tags: 5 | - { name: 'sourceability_instrumentation.profiler' } 6 | -------------------------------------------------------------------------------- /src/Bundle/SourceabilityInstrumentationBundle.php: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Profiler/SpxProfiler.php: -------------------------------------------------------------------------------- 1 | enabled = (bool) getenv('SPX_ENABLED'); 17 | } 18 | 19 | public function start(string $name, ?string $kind = null): void 20 | { 21 | if ($this->enabled && \function_exists('spx_profiler_start')) { 22 | spx_profiler_start(); 23 | } 24 | } 25 | 26 | public function stop(?Throwable $exception = null): void 27 | { 28 | if ($this->enabled && \function_exists('spx_profiler_stop')) { 29 | spx_profiler_stop(); 30 | } 31 | } 32 | 33 | public function stopAndIgnore(): void 34 | { 35 | if ($this->enabled && \function_exists('spx_profiler_stop')) { 36 | spx_profiler_stop(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sourceability 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 | -------------------------------------------------------------------------------- /src/Profiler/ProfilerChain.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private iterable $profilers; 15 | 16 | /** 17 | * @param iterable $profilers 18 | */ 19 | public function __construct(iterable $profilers) 20 | { 21 | $this->profilers = $profilers; 22 | } 23 | 24 | public function start(string $name, ?string $kind = null): void 25 | { 26 | foreach ($this->profilers as $profiler) { 27 | $profiler->start($name, $kind); 28 | } 29 | } 30 | 31 | public function stop(?Throwable $exception = null): void 32 | { 33 | foreach ($this->profilers as $profiler) { 34 | $profiler->stop($exception); 35 | } 36 | } 37 | 38 | public function stopAndIgnore(): void 39 | { 40 | foreach ($this->profilers as $profiler) { 41 | $profiler->stopAndIgnore(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | .DEFAULT_GOAL := help 3 | 4 | EXEC_YAMLLINT = yamllint 5 | ifeq (, $(shell which -s yamllint)) 6 | EXEC_YAMLLINT = docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/yamllint:1.26 7 | endif 8 | 9 | COMPOSE_EXEC ?= 10 | 11 | # Prefix any command that should be run within the fpm docker container with $(EXEC_FPM) 12 | EXEC_PHP = docker-compose exec php 13 | ifeq (, $(shell which docker-compose)) 14 | EXEC_PHP = 15 | endif 16 | 17 | help: 18 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 19 | 20 | .PHONY: docker-sh 21 | docker-sh: ## Starts a bash session in the php container 22 | docker-compose exec php /bin/bash 23 | 24 | .PHONY: docker-up 25 | docker-up: ## Start Docker containers 26 | docker-compose up --detach --build --remove-orphans 27 | 28 | .PHONY: yamllint 29 | yamllint: ## Lints yaml files 30 | $(EXEC_YAMLLINT) -c .yamllint.yaml --strict . 31 | 32 | .PHONY: phpstan 33 | phpstan: ## Static analysis 34 | $(EXEC_PHP) phpstan 35 | 36 | .PHONY: cs 37 | cs: ## Coding standards check 38 | $(EXEC_PHP) ecs check 39 | 40 | .PHONY: cs-fix 41 | cs-fix: ## Coding standards fix 42 | $(EXEC_PHP) ecs check --fix 43 | 44 | .PHONY: phpunit 45 | phpunit: ## Unit tests 46 | $(EXEC_PHP) phpunit 47 | 48 | .PHONY: all 49 | all: phpstan yamllint cs phpunit ## Runs all test/lint targets 50 | -------------------------------------------------------------------------------- /src/Profiler/TidewaysProfiler.php: -------------------------------------------------------------------------------- 1 | enabled = class_exists('Tideways\Profiler'); 19 | } 20 | 21 | public function start(string $name, ?string $kind = null): void 22 | { 23 | if (!$this->enabled) { 24 | return; 25 | } 26 | 27 | if (null !== $kind) { 28 | $transactionName = sprintf('%s_%s', $kind, $name); 29 | } else { 30 | $transactionName = $name; 31 | } 32 | 33 | Profiler::start(); 34 | Profiler::setTransactionName($transactionName); 35 | } 36 | 37 | public function stop(?Throwable $exception = null): void 38 | { 39 | if (!$this->enabled) { 40 | return; 41 | } 42 | 43 | if (null !== $exception) { 44 | Profiler::logException($exception); 45 | } 46 | 47 | Profiler::stop(); 48 | } 49 | 50 | public function stopAndIgnore(): void 51 | { 52 | if (!$this->enabled) { 53 | return; 54 | } 55 | 56 | Profiler::ignoreTransaction(); 57 | Profiler::stop(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Profiler/NewRelicProfiler.php: -------------------------------------------------------------------------------- 1 | newRelicInteractor = $newRelicInteractor; 21 | $this->newRelicConfig = $newRelicConfig; 22 | } 23 | 24 | public function start(string $name, ?string $kind = null): void 25 | { 26 | if (null !== $kind) { 27 | $transactionName = sprintf('%s_%s', $kind, $name); 28 | } else { 29 | $transactionName = $name; 30 | } 31 | 32 | $this->newRelicInteractor->endTransaction(); 33 | $this->newRelicInteractor->startTransaction($this->newRelicConfig->getName()); 34 | $this->newRelicInteractor->enableBackgroundJob(); 35 | $this->newRelicInteractor->setTransactionName($transactionName); 36 | } 37 | 38 | public function stop(?Throwable $exception = null): void 39 | { 40 | if (null !== $exception) { 41 | $this->newRelicInteractor->noticeThrowable($exception); 42 | } 43 | 44 | $this->newRelicInteractor->endTransaction(); 45 | } 46 | 47 | public function stopAndIgnore(): void 48 | { 49 | $this->newRelicInteractor->endTransaction(true); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/EventListener/CommandProfilerListener.php: -------------------------------------------------------------------------------- 1 | profiler = $profiler; 21 | } 22 | 23 | public static function getSubscribedEvents() 24 | { 25 | return [ 26 | ConsoleEvents::COMMAND => 'onCommand', 27 | ConsoleEvents::TERMINATE => 'onTerminate', 28 | ConsoleEvents::ERROR => 'onError', 29 | ]; 30 | } 31 | 32 | public function onCommand(ConsoleCommandEvent $event): void 33 | { 34 | $commandName = 'unknown'; 35 | if (null !== $event->getCommand() 36 | && null !== $event->getCommand() 37 | ->getName() 38 | ) { 39 | $commandName = $event->getCommand() 40 | ->getName() 41 | ; 42 | } 43 | 44 | $this->profiler->start($commandName, 'command'); 45 | } 46 | 47 | public function onTerminate(ConsoleTerminateEvent $event): void 48 | { 49 | $this->profiler->stop(); 50 | } 51 | 52 | public function onError(ConsoleErrorEvent $event): void 53 | { 54 | $this->profiler->stop($event->getError()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | checkMissingIterableValueType: false 3 | 4 | inferPrivatePropertyTypeFromConstructor: true 5 | 6 | level: max 7 | 8 | paths: 9 | - src/ 10 | - tests/ 11 | 12 | ignoreErrors: 13 | - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::#' 14 | - '#[Ff]unction (dd_?trace|DDTrace\\)\S+ (is )?not found#' 15 | - '#Call to static method [^(]+\(\) on an unknown class Tideways\\Profiler.#' 16 | - message: '#Call to function method_exists\(\) with Symfony\\Component\\HttpFoundation\\RequestStack and .getMainRequest. will always evaluate to true#' 17 | reportUnmatched: false 18 | - message: '#Call to an undefined method Symfony\\Component\\HttpFoundation\\RequestStack::getMasterRequest\(\).#' 19 | reportUnmatched: false 20 | - message: '#Call to deprecated method getMasterRequest\(\) of class Symfony\\Component\\HttpFoundation\\RequestStack#' 21 | reportUnmatched: false 22 | - message: '#Call to deprecated method getNestedExceptions.+#' 23 | reportUnmatched: false 24 | - message: '#Call to function method_exists\(\) with Symfony\\Component\\Messenger\\Exception\\HandlerFailedException and .getNestedExceptions. will always evaluate to true.#' 25 | reportUnmatched: false 26 | 27 | scanDirectories: 28 | - vendor/datadog/dd-trace/src/DDTrace/ 29 | 30 | includes: 31 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon 32 | - vendor/phpstan/phpstan-strict-rules/rules.neon 33 | - vendor/phpstan/phpstan/conf/bleedingEdge.neon 34 | - vendor/jangregor/phpstan-prophecy/extension.neon 35 | - vendor/phpstan/phpstan-phpunit/extension.neon 36 | - vendor/phpstan/phpstan-phpunit/rules.neon 37 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | # From https://github.com/ergebnis/php-library-template/blob/b5f31f245704ffac244c5b08231f417a5084c84f/.yamllint.yaml 2 | extends: 'default' 3 | 4 | ignore: | 5 | vendor/ 6 | 7 | rules: 8 | braces: 9 | max-spaces-inside-empty: 0 10 | max-spaces-inside: 1 11 | min-spaces-inside-empty: 0 12 | min-spaces-inside: 1 13 | brackets: 14 | max-spaces-inside-empty: 0 15 | max-spaces-inside: 0 16 | min-spaces-inside-empty: 0 17 | min-spaces-inside: 0 18 | colons: 19 | max-spaces-after: 1 20 | max-spaces-before: 0 21 | commas: 22 | max-spaces-after: 1 23 | max-spaces-before: 0 24 | min-spaces-after: 1 25 | comments: 26 | ignore-shebangs: true 27 | min-spaces-from-content: 1 28 | require-starting-space: true 29 | comments-indentation: 'enable' 30 | document-end: 31 | present: false 32 | document-start: 33 | present: false 34 | indentation: 35 | check-multi-line-strings: false 36 | indent-sequences: true 37 | spaces: 4 38 | empty-lines: 39 | max-end: 0 40 | max-start: 0 41 | max: 1 42 | empty-values: 43 | forbid-in-block-mappings: true 44 | forbid-in-flow-mappings: true 45 | hyphens: 46 | max-spaces-after: 2 47 | key-duplicates: 'enable' 48 | key-ordering: 'disable' 49 | line-length: 'disable' 50 | new-line-at-end-of-file: 'enable' 51 | new-lines: 52 | type: 'unix' 53 | octal-values: 54 | forbid-implicit-octal: true 55 | quoted-strings: 56 | quote-type: 'single' 57 | trailing-spaces: 'enable' 58 | truthy: 59 | allowed-values: 60 | - 'false' 61 | - 'true' 62 | 63 | yaml-files: 64 | - '*.yaml' 65 | - '*.yml' 66 | -------------------------------------------------------------------------------- /tests/EventListener/MessengerProfilerListenerTest.php: -------------------------------------------------------------------------------- 1 | prophesize(ProfilerInterface::class); 26 | 27 | $listener = new MessengerProfilerListener($profiler->reveal()); 28 | 29 | $error = new \Exception('not good'); 30 | 31 | $profiler->stop($error) 32 | ->shouldBeCalled() 33 | ; 34 | 35 | $event = new WorkerMessageFailedEvent(new Envelope(new \stdClass()), 'receiver', $error); 36 | $listener->onReject($event); 37 | } 38 | 39 | public function testOnRejectUnwrapsHandlerFailedException(): void 40 | { 41 | $profiler = $this->prophesize(ProfilerInterface::class); 42 | 43 | $listener = new MessengerProfilerListener($profiler->reveal()); 44 | 45 | $envelope = new Envelope(new \stdClass()); 46 | 47 | $error = new HandlerFailedException($envelope, [$handlerError = new \Exception('not good')]); 48 | 49 | $profiler->stop($handlerError) 50 | ->shouldBeCalled() 51 | ; 52 | 53 | $event = new WorkerMessageFailedEvent($envelope, 'receiver', $error); 54 | $listener->onReject($event); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | services(); 14 | $services->set(ArraySyntaxFixer::class) 15 | ->call('configure', [[ 16 | 'syntax' => 'short', 17 | ]]); 18 | 19 | $containerConfigurator->import(SetList::PSR_12); 20 | $containerConfigurator->import(SetList::PHP_CS_FIXER); 21 | $containerConfigurator->import(SetList::PHP_CS_FIXER_RISKY); 22 | $containerConfigurator->import(SetList::SYMPLIFY); 23 | $containerConfigurator->import(SetList::SYMFONY); 24 | $containerConfigurator->import(SetList::SYMFONY_RISKY); 25 | $containerConfigurator->import(SetList::COMMON); 26 | $containerConfigurator->import(SetList::CLEAN_CODE); 27 | 28 | $parameters = $containerConfigurator->parameters(); 29 | $parameters->set(Option::PATHS, [__DIR__ . '/src', __DIR__ . '/tests']); 30 | $parameters->set(Option::SKIP, [ 31 | MethodChainingIndentationFixer::class => [ 32 | __DIR__ . '/src/Bundle/DependencyInjection/Configuration.php', 33 | ], 34 | MethodChainingNewlineFixer::class => [ 35 | __DIR__ . '/src/Bundle/DependencyInjection/Configuration.php', 36 | ], 37 | YodaStyleFixer::class => [ 38 | __DIR__, 39 | ], 40 | NotOperatorWithSuccessorSpaceFixer::class => [ 41 | __DIR__, 42 | ], 43 | ]); 44 | }; 45 | -------------------------------------------------------------------------------- /src/Bundle/DependencyInjection/SourceabilityInstrumentationExtension.php: -------------------------------------------------------------------------------- 1 | $config 16 | */ 17 | protected function loadInternal(array $config, ContainerBuilder $container): void 18 | { 19 | /** @var array{listeners: array, profilers: array} $config */ 20 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 21 | $loader->load('services.yaml'); 22 | 23 | if ($config['listeners']['command']['enabled']) { 24 | $loader->load('listener_command.yaml'); 25 | } 26 | 27 | if ($config['listeners']['messenger']['enabled']) { 28 | $loader->load('listener_messenger.yaml'); 29 | } 30 | 31 | if ($config['profilers']['datadog']['enabled']) { 32 | $loader->load('profiler_datadog.yaml'); 33 | } 34 | 35 | if ($config['profilers']['newrelic']['enabled']) { 36 | $loader->load('profiler_newrelic.yaml'); 37 | } 38 | 39 | if ($config['profilers']['symfony']['enabled']) { 40 | $loader->load('profiler_symfony.yaml'); 41 | } 42 | 43 | if ($config['profilers']['tideways']['enabled']) { 44 | $loader->load('profiler_tideways.yaml'); 45 | } 46 | 47 | if ($config['profilers']['spx']['enabled']) { 48 | $loader->load('profiler_spx.yaml'); 49 | } 50 | 51 | if (interface_exists('Symfony\\Component\\Messenger\\Middleware\\MiddlewareInterface')) { 52 | $loader->load('messenger_middleware.yaml'); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Bundle/DependencyInjection/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, []); 23 | 24 | static::assertArrayHasKey('profilers', $config); 25 | static::assertArrayHasKey('listeners', $config); 26 | 27 | static::assertArrayHasKey('tideways', $config['profilers']); 28 | static::assertArrayHasKey('enabled', $config['profilers']['tideways']); 29 | static::assertFalse($config['profilers']['tideways']['enabled']); 30 | 31 | static::assertArrayHasKey('newrelic', $config['profilers']); 32 | static::assertArrayHasKey('enabled', $config['profilers']['newrelic']); 33 | static::assertFalse($config['profilers']['newrelic']['enabled']); 34 | 35 | static::assertArrayHasKey('datadog', $config['profilers']); 36 | static::assertArrayHasKey('enabled', $config['profilers']['datadog']); 37 | static::assertFalse($config['profilers']['datadog']['enabled']); 38 | 39 | static::assertArrayHasKey('symfony', $config['profilers']); 40 | static::assertArrayHasKey('enabled', $config['profilers']['symfony']); 41 | static::assertFalse($config['profilers']['symfony']['enabled']); 42 | 43 | static::assertArrayHasKey('command', $config['listeners']); 44 | static::assertArrayHasKey('enabled', $config['listeners']['command']); 45 | static::assertFalse($config['listeners']['command']['enabled']); 46 | 47 | static::assertArrayHasKey('messenger', $config['listeners']); 48 | static::assertArrayHasKey('enabled', $config['listeners']['messenger']); 49 | static::assertFalse($config['listeners']['messenger']['enabled']); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | checks: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | php_version: 17 | - '8.0' 18 | - '8.1' 19 | symfony_version: 20 | - '4.4' 21 | - '5.4' 22 | - '6.0' 23 | include: 24 | - php_version: '8.1' 25 | symfony_version: '6.1' 26 | 27 | name: 'PHP ${{ matrix.php_version }} - Symfony ${{ matrix.symfony_version }}' 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | 32 | - 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php_version }} 36 | coverage: none 37 | 38 | # See https://github.com/actions/cache/blob/main/examples.md#php---composer 39 | - name: Get Composer Cache Directory 40 | id: composer-cache 41 | run: | 42 | echo "::set-output name=dir::$(composer config cache-files-dir)" 43 | - uses: actions/cache@v2 44 | with: 45 | path: ${{ steps.composer-cache.outputs.dir }} 46 | key: ${{ runner.os }}-php${{ matrix.php_version }}-symfony${{ matrix.symfony_version }}-composer-${{ hashFiles('**/composer.json') }} 47 | restore-keys: | 48 | ${{ runner.os }}-composer- 49 | 50 | - name: "Install symfony/flex for SYMFONY_REQUIRE" 51 | run: | 52 | composer global config --no-plugins allow-plugins.symfony/flex true 53 | composer global require --no-progress --no-scripts --no-plugins symfony/flex 54 | 55 | - name: 'Install dependencies' 56 | run: SYMFONY_REQUIRE='${{ matrix.symfony_version }}.*' composer update ${{ matrix.composer-flags }} --prefer-dist 57 | 58 | - run: vendor/bin/phpstan 59 | - run: vendor/bin/ecs check 60 | - run: vendor/bin/phpunit 61 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sourceability/instrumentation", 3 | "type": "library", 4 | "description": "Instrument commands/workers/custom code with datadog, newrelic, tideways, symfony.", 5 | "homepage": "https://github.com/sourceability/instrumentation", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Adrien Brault", 10 | "email": "adrien.brault@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.4 || ^8.0" 15 | }, 16 | "require-dev": { 17 | "datadog/dd-trace": "^0.82.0", 18 | "ekino/newrelic-bundle": "^2.2", 19 | "jangregor/phpstan-prophecy": "^1.0", 20 | "phpspec/prophecy-phpunit": "^2.0", 21 | "phpstan/phpstan": "^1.0", 22 | "phpstan/phpstan-deprecation-rules": "^1.0", 23 | "phpstan/phpstan-phpunit": "^1.0", 24 | "phpstan/phpstan-strict-rules": "^1.0", 25 | "phpunit/phpunit": "^9.5", 26 | "symfony/config": "^4.4 || ^5.4 || ^6.0 || ^7.0", 27 | "symfony/console": "^4.4 || ^5.4 || ^6.0 || ^7.0", 28 | "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.0 || ^7.0", 29 | "symfony/event-dispatcher": "^4.4 || ^5.4 || ^6.0 || ^7.0", 30 | "symfony/http-foundation": "^4.4 || ^5.4 || ^6.0 || ^7.0", 31 | "symfony/http-kernel": "^4.4 || ^5.4 || ^6.0 || ^7.0", 32 | "symfony/messenger": "^4.4 || ^5.4 || ^6.0 || ^7.0", 33 | "symfony/stopwatch": "^4.4 || ^5.4 || ^6.0 || ^7.0", 34 | "symplify/easy-coding-standard": "^9.3" 35 | }, 36 | "suggest": { 37 | "symfony/config": "^4.4 || ^5.4 || ^6.0 || ^7.0", 38 | "symfony/console": "^4.4 || ^5.4 || ^6.0 || ^7.0", 39 | "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.0 || ^7.0", 40 | "symfony/event-dispatcher": "^4.4 || ^5.4 || ^6.0 || ^7.0", 41 | "symfony/http-foundation": "^4.4 || ^5.4 || ^6.0 || ^7.0", 42 | "symfony/http-kernel": "^4.4 || ^5.4 || ^6.0 || ^7.0", 43 | "symfony/messenger": "^4.4 || ^5.4 || ^6.0 || ^7.0", 44 | "symfony/stopwatch": "^4.4 || ^5.4 || ^6.0 || ^7.0" 45 | }, 46 | "config": { 47 | "preferred-install": "dist", 48 | "sort-packages": true 49 | }, 50 | "autoload": { 51 | "psr-4": { 52 | "Sourceability\\Instrumentation\\": "src/" 53 | } 54 | }, 55 | "autoload-dev": { 56 | "psr-4": { 57 | "Sourceability\\Instrumentation\\Test\\": "tests/" 58 | } 59 | }, 60 | "support": { 61 | "issues": "https://github.com/sourceability/instrumentation/issues", 62 | "source": "https://github.com/sourceability/instrumentation" 63 | }, 64 | "extra": { 65 | "branch-alias": { 66 | "dev-main": "0.2.x-dev" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/EventListener/MessengerProfilerListener.php: -------------------------------------------------------------------------------- 1 | profiler = $profiler; 23 | } 24 | 25 | public static function getSubscribedEvents() 26 | { 27 | return [ 28 | WorkerMessageReceivedEvent::class => [['onInvoke', 2048]], 29 | WorkerMessageHandledEvent::class => [['onAcknowledge', -2048]], 30 | WorkerMessageFailedEvent::class => [['onReject', -2048]], 31 | WorkerStartedEvent::class => [['onPing', -2048]], 32 | ]; 33 | } 34 | 35 | public function onInvoke(WorkerMessageReceivedEvent $event): void 36 | { 37 | $transactionName = \get_class($event->getEnvelope()->getMessage()); 38 | 39 | $this->profiler->stop(); 40 | $this->profiler->start($transactionName, 'messenger'); 41 | } 42 | 43 | public function onAcknowledge(WorkerMessageHandledEvent $event): void 44 | { 45 | $this->profiler->stop(); 46 | } 47 | 48 | public function onReject(WorkerMessageFailedEvent $event): void 49 | { 50 | $throwable = $event->getThrowable(); 51 | 52 | $nestedExceptions = []; 53 | 54 | if (interface_exists(WrappedExceptionsInterface::class) 55 | && $throwable instanceof WrappedExceptionsInterface 56 | ) { 57 | $nestedExceptions = $throwable->getWrappedExceptions(); 58 | } elseif ($throwable instanceof HandlerFailedException 59 | && method_exists($throwable, 'getNestedExceptions') 60 | ) { 61 | $nestedExceptions = $throwable->getNestedExceptions(); 62 | } 63 | 64 | $firstNestedException = reset($nestedExceptions); 65 | 66 | $throwable = false !== $firstNestedException ? $firstNestedException : $throwable; 67 | 68 | $this->profiler->stop($throwable); 69 | } 70 | 71 | public function onPing(WorkerStartedEvent $event): void 72 | { 73 | $this->profiler->stopAndIgnore(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Messenger/ProfilerMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | prophesize(ProfilerInterface::class); 31 | $profiler->start(Argument::cetera())->shouldBeCalledOnce(); 32 | $profiler->stop(Argument::cetera())->shouldBeCalledOnce(); 33 | 34 | $middleware = new ProfilerMiddleware($profiler->reveal(), null); 35 | 36 | $envelope = new Envelope(new \stdClass(), [new ReceivedStamp('foo')]); 37 | 38 | $nestedStack = $this->prophesize(StackInterface::class); 39 | $nestedStack->next() 40 | ->willReturn(new class() implements MiddlewareInterface { 41 | public function handle(Envelope $envelope, StackInterface $stack): Envelope 42 | { 43 | // noop 44 | return $envelope; 45 | } 46 | }) 47 | ; 48 | 49 | $stack = $this->prophesize(StackInterface::class); 50 | $stack->next() 51 | ->willReturn(new class($middleware, $nestedStack->reveal()) implements MiddlewareInterface { 52 | private ProfilerMiddleware $middleware; 53 | 54 | private StackInterface $stack; 55 | 56 | public function __construct(ProfilerMiddleware $middleware, StackInterface $stack) 57 | { 58 | $this->middleware = $middleware; 59 | $this->stack = $stack; 60 | } 61 | 62 | public function handle(Envelope $envelope, StackInterface $stack): Envelope 63 | { 64 | // this might be our handler, that ends up having other handlers involved 65 | // ... triggering a nested \Sourceability\Instrumentation\Messenger\ProfilerMiddleware::handle 66 | 67 | return $this->middleware->handle($envelope, $this->stack); 68 | } 69 | }) 70 | ; 71 | 72 | $middleware->handle($envelope, $stack->reveal()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Profiler/DatadogProfiler.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 28 | } 29 | 30 | public function start(string $name, ?string $kind = null): void 31 | { 32 | if (!$this->isEnabled()) { 33 | return; 34 | } 35 | 36 | if (dd_trace_env_config('DD_TRACE_GENERATE_ROOT_SPAN')) { 37 | $this->logger->error( 38 | sprintf('You should set DD_TRACE_GENERATE_ROOT_SPAN=0 when using %s.', self::class) 39 | ); 40 | } 41 | 42 | if (!dd_trace_env_config('DD_TRACE_AUTO_FLUSH_ENABLED')) { 43 | $this->logger->error( 44 | sprintf('You should set DD_TRACE_AUTO_FLUSH_ENABLED=1 when using %s.', self::class) 45 | ); 46 | } 47 | 48 | $kind ??= 'custom'; 49 | $operationName = sprintf('symfony.%s', $kind); 50 | 51 | $this->scope = GlobalTracer::get()->startRootSpan($operationName, [ 52 | 'ignore_active_span' => true, 53 | 'finish_span_on_close' => true, 54 | ]); 55 | 56 | $this->scope->getSpan() 57 | ->setResource($name) 58 | ; 59 | $this->scope->getSpan() 60 | ->setTag(Tag::SPAN_TYPE, Type::CLI) 61 | ; 62 | $this->scope->getSpan() 63 | ->setTag(Tag::SERVICE_NAME, ddtrace_config_app_name($operationName)) 64 | ; 65 | } 66 | 67 | public function stop(?Throwable $exception = null): void 68 | { 69 | if (null === $this->scope) { 70 | return; 71 | } 72 | 73 | if (null !== $exception) { 74 | $this->scope->getSpan() 75 | ->setError($exception) 76 | ; 77 | } 78 | 79 | $this->scope->close(); 80 | $this->scope = null; 81 | } 82 | 83 | public function stopAndIgnore(): void 84 | { 85 | if (null === $this->scope) { 86 | return; 87 | } 88 | 89 | $this->scope = null; 90 | 91 | // https://github.com/DataDog/dd-trace-php/issues/1533#issuecomment-1059211743 92 | ini_set('datadog.trace.enabled', '0'); 93 | ini_set('datadog.trace.enabled', '1'); 94 | 95 | GlobalTracer::set(new Tracer()); 96 | } 97 | 98 | private function isEnabled(): bool 99 | { 100 | return \extension_loaded('ddtrace') 101 | && ddtrace_config_trace_enabled() 102 | ; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Messenger/ProfilerMiddleware.php: -------------------------------------------------------------------------------- 1 | profiler = $profiler; 27 | $this->requestStack = $requestStack; 28 | } 29 | 30 | public function handle(Envelope $envelope, StackInterface $stack): Envelope 31 | { 32 | $transactionName = \get_class($envelope->getMessage()); 33 | 34 | $skip = false; 35 | if (null !== $this->requestStack 36 | && null !== (method_exists( 37 | $this->requestStack, 38 | 'getMainRequest' 39 | ) ? $this->requestStack->getMainRequest() : $this->requestStack->getMasterRequest()) 40 | ) { 41 | $skip = true; 42 | } 43 | if (null === $envelope->last(ReceivedStamp::class)) { 44 | $skip = true; 45 | } 46 | 47 | if ($skip) { 48 | // Do not profile if we are within a web context 49 | return $stack->next() 50 | ->handle($envelope, $stack) 51 | ; 52 | } 53 | 54 | $shouldStop = false; 55 | if (!$this->started) { 56 | $this->profiler->start($transactionName, 'messenger'); 57 | 58 | $this->started = true; 59 | $shouldStop = true; 60 | } 61 | 62 | try { 63 | return $stack->next() 64 | ->handle($envelope, $stack) 65 | ; 66 | } catch (\Exception $exception) { 67 | $nestedExceptions = null; 68 | 69 | if (interface_exists(WrappedExceptionsInterface::class) 70 | && $exception instanceof WrappedExceptionsInterface 71 | ) { 72 | $nestedExceptions = $exception->getWrappedExceptions(); 73 | } elseif ($exception instanceof HandlerFailedException 74 | && method_exists($exception, 'getNestedExceptions') 75 | ) { 76 | $nestedExceptions = $exception->getNestedExceptions(); 77 | } 78 | 79 | if ($shouldStop && null !== $nestedExceptions) { 80 | $firstNestedException = reset($nestedExceptions); 81 | 82 | $this->profiler->stop(false !== $firstNestedException ? $firstNestedException : $exception); 83 | $this->started = false; 84 | 85 | $shouldStop = false; 86 | } 87 | 88 | throw $exception; 89 | } finally { 90 | if ($shouldStop) { 91 | $this->profiler->stop(); 92 | $this->started = false; 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Bundle/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 16 | 17 | $rootNode 18 | ->children() 19 | ->arrayNode('profilers') 20 | ->addDefaultsIfNotSet() 21 | ->children() 22 | ->arrayNode('tideways') 23 | ->canBeEnabled() 24 | ->info( 25 | <<<'CODE_SAMPLE' 26 | See https://support.tideways.com/documentation/features/application-monitoring/application-performance-overview.html 27 | CODE_SAMPLE 28 | ) 29 | ->end() 30 | ->arrayNode('newrelic') 31 | ->canBeEnabled() 32 | ->info( 33 | <<<'CODE_SAMPLE' 34 | See https://docs.newrelic.com/docs/agents/php-agent/getting-started/introduction-new-relic-php/ 35 | This requires https://github.com/ekino/EkinoNewRelicBundle 36 | CODE_SAMPLE 37 | ) 38 | ->end() 39 | ->arrayNode('datadog') 40 | ->canBeEnabled() 41 | ->info( 42 | <<<'CODE_SAMPLE' 43 | See https://docs.datadoghq.com/tracing/setup_overview/setup/php/ 44 | CODE_SAMPLE 45 | ) 46 | ->end() 47 | ->arrayNode('symfony') 48 | ->canBeEnabled() 49 | ->info( 50 | <<<'CODE_SAMPLE' 51 | This "hacks" the symfony web profiler to create profiles in non web contexts like workers, commands. 52 | This is really useful for development along with https://github.com/sourceability/console-toolbar-bundle 53 | CODE_SAMPLE 54 | ) 55 | ->end() 56 | ->arrayNode('spx') 57 | ->canBeEnabled() 58 | ->info(<<<'CODE_SAMPLE' 59 | See https://github.com/NoiseByNorthwest/php-spx 60 | CODE_SAMPLE 61 | ) 62 | ->end() 63 | ->end() 64 | ->end() 65 | ->arrayNode('listeners') 66 | ->addDefaultsIfNotSet() 67 | ->children() 68 | ->arrayNode('command') 69 | ->canBeEnabled() 70 | ->info('Automatically instrument commands') 71 | ->end() 72 | ->arrayNode('messenger') 73 | ->canBeEnabled() 74 | ->info('Automatically instrument messenger workers') 75 | ->end() 76 | ->end() 77 | ->end() 78 | ->end() 79 | ; 80 | 81 | return $treeBuilder; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sourceability/instrumentation 2 | 3 | This library provides a simple interface to start and stop instrumenting code with APMs. 4 | 5 | Symfony commands and messenger workers have built in symfony event listeners which is convenient because most 6 | APMs usually don't support profiling workers out of the box. 7 | 8 | Install the library using composer: 9 | ``` 10 | $ composer require sourceability/instrumentation 11 | ``` 12 | 13 | ## Bundle 14 | 15 | This library includes an optional Symfony bundle that you can install by updating `config/bundles.php`: 16 | ``` 17 | return [ 18 | Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], 19 | // ... 20 | Sourceability\Instrumentation\Bundle\SourceabilityInstrumentationBundle::class => ['all' => true], 21 | ]; 22 | ``` 23 | 24 | Bundle configuration reference: 25 | ```yaml 26 | # Default configuration for extension with alias: "sourceability_instrumentation" 27 | sourceability_instrumentation: 28 | profilers: 29 | 30 | # See https://support.tideways.com/documentation/features/application-monitoring/application-performance-overview.html 31 | tideways: 32 | enabled: false 33 | 34 | # See https://docs.newrelic.com/docs/agents/php-agent/getting-started/introduction-new-relic-php/ 35 | # This requires https://github.com/ekino/EkinoNewRelicBundle 36 | newrelic: 37 | enabled: false 38 | 39 | # See https://docs.datadoghq.com/tracing/setup_overview/setup/php/ 40 | datadog: 41 | enabled: false 42 | 43 | # This "hacks" the symfony web profiler to create profiles in non web contexts like workers, commands. 44 | # This is really useful for development along with https://github.com/sourceability/console-toolbar-bundle 45 | symfony: 46 | enabled: false 47 | 48 | # See https://github.com/NoiseByNorthwest/php-spx 49 | spx: 50 | enabled: false 51 | listeners: 52 | 53 | # Automatically instrument commands 54 | command: 55 | enabled: false 56 | 57 | # Automatically instrument messenger workers 58 | messenger: 59 | enabled: false 60 | ``` 61 | 62 | Messenger profiling is also available with a middleware. 63 | 64 | Please note that you should use either the middleware, or the listener, but not both, 65 | as this will distort the statistics sent to your APM/monitoring. 66 | 67 | ```yaml 68 | framework: 69 | messenger: 70 | buses: 71 | messenger.bus.default: 72 | middleware: 73 | - Sourceability\Instrumentation\Messenger\ProfilerMiddleware 74 | ``` 75 | 76 | ## Instrumenting a long running command 77 | 78 | ```php 79 | profiler = $profiler; 100 | } 101 | 102 | protected function execute(InputInterface $input, OutputInterface $output): int 103 | { 104 | $this->profiler->stop(); 105 | 106 | $pager = new Pagerfanta(...); 107 | 108 | foreach ($pager as $pageResults) { 109 | $this->profiler->start('index_batch'); 110 | 111 | $this->indexer->index($pageResults); 112 | 113 | $this->profiler->stop(); 114 | }; 115 | 116 | return 0; 117 | } 118 | } 119 | ``` 120 | -------------------------------------------------------------------------------- /src/Profiler/SymfonyProfiler.php: -------------------------------------------------------------------------------- 1 | profiler = $profiler; 32 | $this->stopwatch = $stopwatch; 33 | $this->requestStack = $requestStack; 34 | } 35 | 36 | public function start(string $name, ?string $kind = null): void 37 | { 38 | if (null === $this->profiler 39 | || null === $this->stopwatch 40 | ) { 41 | return; 42 | } 43 | 44 | if (null === $kind) { 45 | $kind = 'custom'; 46 | } 47 | 48 | $uri = sprintf('http://%s/%s', $kind, $name); 49 | 50 | $this->request = Request::create($uri, $kind); 51 | $this->request->server->set('REQUEST_TIME_FLOAT', microtime(true)); 52 | 53 | $this->profiler->reset(); 54 | $this->stopwatch->openSection(); 55 | $this->mainEvent = $this->stopwatch->start($kind, 'section'); 56 | } 57 | 58 | public function stop(?Throwable $exception = null): void 59 | { 60 | if (null === $this->mainEvent 61 | || null === $this->request 62 | || null === $this->profiler 63 | || null === $this->stopwatch 64 | ) { 65 | return; 66 | } 67 | 68 | $this->mainEvent->ensureStopped(); 69 | $this->mainEvent = null; // make sure "nested" profiles aren't saved twice, for example messenger within command 70 | 71 | $responseStatus = null === $exception ? Response::HTTP_OK : Response::HTTP_INTERNAL_SERVER_ERROR; 72 | 73 | $response = new Response('', $responseStatus); 74 | 75 | $requestStackToCleanUp = null; 76 | if (null !== $this->requestStack 77 | && null === (method_exists( 78 | $this->requestStack, 79 | 'getMainRequest' 80 | ) ? $this->requestStack->getMainRequest() : $this->requestStack->getMasterRequest()) 81 | ) { 82 | // Fixes: Notice: Trying to get property 'attributes' of non-object 83 | // See https://github.com/symfony/symfony/blob/e34cd7dd2c6d0b30d24cad443b8f964daa841d71/src/Symfony/Component/HttpKernel/DataCollector/RequestDataCollector.php#L109 84 | 85 | $this->requestStack->push($this->request); 86 | $requestStackToCleanUp = $this->requestStack; 87 | } 88 | 89 | $profile = $this->profiler->collect($this->request, $response, $exception); 90 | 91 | if (null !== $profile) { 92 | if ($this->stopwatch->isStarted('__section__')) { 93 | $this->stopwatch->stopSection($profile->getToken()); 94 | } 95 | 96 | $this->profiler->saveProfile($profile); 97 | } 98 | 99 | if (null !== $requestStackToCleanUp) { 100 | $poppedRequest = $requestStackToCleanUp->pop(); 101 | 102 | \assert($this->request === $poppedRequest); 103 | } 104 | $this->request = null; 105 | } 106 | 107 | public function stopAndIgnore(): void 108 | { 109 | $this->request = null; 110 | 111 | if (null !== $this->mainEvent) { 112 | $this->mainEvent->ensureStopped(); 113 | } 114 | 115 | $this->mainEvent = null; 116 | } 117 | } 118 | --------------------------------------------------------------------------------