├── 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 |
--------------------------------------------------------------------------------