├── tests ├── test-socket-server │ ├── .gitignore │ ├── test-socket-server │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── Unit │ ├── CoreAgent │ │ ├── emulated-unknown-error.sh │ │ ├── emulated-core-agent-glibc-error.sh │ │ ├── emulated-happy-path.sh │ │ └── LauncherTest.php │ ├── Events │ │ ├── Span │ │ │ └── SpanIdTest.php │ │ ├── Request │ │ │ └── RequestIdTest.php │ │ ├── RegisterMessageTest.php │ │ └── Tag │ │ │ ├── TagRequestTest.php │ │ │ └── TagSpanTest.php │ ├── Helper │ │ ├── ComposerPackagesCheckTest.php │ │ ├── MemoryUsageTest.php │ │ ├── LocateFileOrFolderTest.php │ │ ├── DetermineHostname │ │ │ └── DetermineHostnameWithConfigOverrideTest.php │ │ ├── BacktraceTest.php │ │ ├── Superglobals │ │ │ └── SuperglobalsArraysTest.php │ │ ├── FindRequestHeadersUsingServerGlobalTest.php │ │ ├── FindRootPackageGitShaWithHerokuAndConfigOverrideTest.php │ │ ├── TimerTest.php │ │ └── FindApplicationRoot │ │ │ └── FindApplicationRootWithConfigOverrideTest.php │ ├── Config │ │ ├── Source │ │ │ ├── DefaultSourceTest.php │ │ │ ├── NullSourceTest.php │ │ │ ├── UserSettingsSourceTest.php │ │ │ ├── EnvSourceTest.php │ │ │ └── DerivedSourceTest.php │ │ ├── ConfigKeyTest.php │ │ ├── TypeCoercion │ │ │ ├── CoerceJsonTest.php │ │ │ └── CoerceBooleanTest.php │ │ └── IgnoredEndpointsTest.php │ ├── Errors │ │ └── ScoutClient │ │ │ └── CompressPayloadTest.php │ ├── Extension │ │ ├── DoNotInvokeAnyExtensionCapabilitiesTest.php │ │ └── VersionTest.php │ ├── TestHelper.php │ ├── Laravel │ │ ├── View │ │ │ └── Engine │ │ │ │ └── EngineImplementationWithGetCompilerMethod.php │ │ ├── Facades │ │ │ └── ScoutApmTest.php │ │ ├── Database │ │ │ └── QueryListenerTest.php │ │ ├── Queue │ │ │ └── JobQueueListenerTest.php │ │ └── Middleware │ │ │ ├── MiddlewareInstrumentTest.php │ │ │ ├── IgnoredEndpointsTest.php │ │ │ └── SendRequestToScoutTest.php │ └── ScoutApmBundle │ │ ├── ScoutApmAgentFactoryTest.php │ │ ├── DependencyInjection │ │ ├── ScoutApmExtensionTest.php │ │ └── ConfigurationTest.php │ │ └── ScoutApmBundleTest.php ├── check-memory-leaks.sh ├── Integration │ ├── MessageCapturingConnectorDelegator.php │ └── CheckScoutApmKeyListener.php ├── isolated-memory-test.php ├── isolated-error-capture-test.php └── psalm-stubs.php ├── .gitignore ├── src ├── CoreAgent │ ├── Manager.php │ ├── Verifier.php │ ├── AutomaticDownloadAndLaunchManager.php │ └── Manifest.php ├── Connector │ ├── CommandWithParent.php │ ├── CommandWithChildren.php │ ├── Command.php │ ├── Exception │ │ ├── NotConnected.php │ │ └── FailedToConnect.php │ ├── Connector.php │ └── ConnectionAddress.php ├── Config │ ├── TypeCoercion │ │ ├── CoerceInt.php │ │ ├── CoerceString.php │ │ ├── CoerceType.php │ │ ├── CoerceJson.php │ │ └── CoerceBoolean.php │ ├── Source │ │ ├── NullSource.php │ │ ├── ConfigSource.php │ │ ├── UserSettingsSource.php │ │ └── EnvSource.php │ ├── IgnoredEndpoints.php │ └── Helper │ │ └── RequireValidFilteredParameters.php ├── Helper │ ├── Platform.php │ ├── DetermineHostname │ │ ├── DetermineHostname.php │ │ └── DetermineHostnameWithConfigOverride.php │ ├── FindApplicationRoot │ │ ├── FindApplicationRoot.php │ │ └── FindApplicationRootWithConfigOverride.php │ ├── RootPackageGitSha │ │ ├── FindRootPackageGitSha.php │ │ └── FindRootPackageGitShaWithHerokuAndConfigOverride.php │ ├── FindRequestHeaders │ │ ├── FindRequestHeaders.php │ │ └── FindRequestHeadersUsingServerGlobal.php │ ├── MemoryUsage.php │ ├── LibcDetection.php │ ├── RecursivelyCountSpans.php │ ├── Superglobals │ │ └── Superglobals.php │ ├── LocateFileOrFolder │ │ ├── LocateFileOrFolderUsingFilesystem.php │ │ └── LocateFileOrFolder.php │ ├── FormatUrlPathAndQuery.php │ ├── ComposerPackagesCheck.php │ └── Timer.php ├── Laravel │ ├── Router │ │ ├── AutomaticallyDetermineControllerName.php │ │ ├── DetermineLaravelControllerName.php │ │ ├── DetermineDingoControllerName.php │ │ └── RuntimeDetermineControllerNameStrategy.php │ ├── Middleware │ │ ├── IgnoredEndpoints.php │ │ ├── MiddlewareInstrument.php │ │ ├── SendRequestToScout.php │ │ └── ActionInstrument.php │ ├── Database │ │ └── QueryListener.php │ ├── config │ │ └── scout_apm.php │ ├── Facades │ │ └── ScoutApm.php │ ├── Queue │ │ └── JobQueueListener.php │ └── Console │ │ ├── ConsoleListener.php │ │ └── Commands │ │ └── CoreAgent.php ├── Errors │ ├── ScoutClient │ │ ├── CompressPayload.php │ │ └── ErrorReportingClient.php │ ├── NoErrorHandling.php │ └── ErrorHandling.php ├── Extension │ ├── ExtensionCapabilities.php │ ├── DoNotInvokeAnyExtensionCapabilities.php │ ├── Version.php │ └── PotentiallyAvailableExtensionCapabilities.php ├── Cache │ ├── DevNullCache.php │ ├── DevNullCacheSimpleCache1.php │ └── DevNullCacheSimpleCache2And3.php ├── Events │ ├── Request │ │ ├── Exception │ │ │ └── SpanLimitReached.php │ │ └── RequestId.php │ ├── Span │ │ ├── SpanId.php │ │ └── SpanReference.php │ ├── RegisterMessage.php │ └── Tag │ │ ├── TagRequest.php │ │ ├── Tag.php │ │ └── TagSpan.php ├── ScoutApmBundle │ ├── symfony-version-compatibility.php │ ├── DependencyInjection │ │ ├── ScoutApmExtension.php │ │ └── Configuration.php │ ├── ScoutApmAgentFactory.php │ ├── EventListener │ │ └── DoctrineSqlLogger.php │ ├── Resources │ │ └── config │ │ │ └── scoutapm.xml │ ├── Twig │ │ └── TwigDecorator.php │ └── ScoutApmBundle.php ├── Middleware │ └── ScoutApmMiddleware.php ├── MongoDB │ └── QueryTimeCollector.php └── Logger │ └── FilteredLogLevelDecorator.php ├── TODO.md ├── ext └── php-with-scoutapm.sh ├── stub └── scoutapm.stub.php ├── .github ├── fixtures │ ├── laravel11-app-with-exceptions.php │ ├── laravel8-exception-handler.php │ ├── laravel9-exception-handler.php │ ├── laravel5-6-exception-handler.php │ └── laravel7-exception-handler.php └── workflows │ ├── roave-backwards-compatibility-check.sh │ └── release-on-milestone-closed-triggering-release-event.yml ├── phpcs.xml.dist ├── LICENSE ├── DEVELOPMENT.md ├── .phpstorm.meta.php ├── Makefile ├── phpunit.xml.dist └── psalm.xml.dist /tests/test-socket-server/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | phpunit.xml 3 | composer.phar 4 | .DS_STORE 5 | .idea 6 | .phpcs-cache 7 | build 8 | .phpunit.result.cache 9 | -------------------------------------------------------------------------------- /tests/test-socket-server/test-socket-server: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scoutapp/scout-apm-php/HEAD/tests/test-socket-server/test-socket-server -------------------------------------------------------------------------------- /tests/Unit/CoreAgent/emulated-unknown-error.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | >&2 echo "Something bad went wrong" 6 | exit 1 7 | -------------------------------------------------------------------------------- /src/CoreAgent/Manager.php: -------------------------------------------------------------------------------- 1 | "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | TODO: 2 | 3 | * Fix value as string in tagrequest 4 | * Test Connector registers & sends correctly 5 | * Clarify API 6 | - Should Spans be in control of holding their own tags, or only agent-object creating tags? 7 | - Why have both "Agent" and "Request"? 8 | * Create AppMetadata message 9 | * Script to download & start CA 10 | -------------------------------------------------------------------------------- /src/Config/TypeCoercion/CoerceInt.php: -------------------------------------------------------------------------------- 1 | &2 echo "/tmp/scout_apm_core/scout_apm_core-v1.2.7-x86_64-unknown-linux-gnu/core-agent: /lib64/libc.so.6: version \`GLIBC_2.18' not found (required by /tmp/scout_apm_core/scout_apm_core-v1.2.7-x86_64-unknown-linux-gnu/core-agent)" 6 | exit 1 7 | -------------------------------------------------------------------------------- /src/Helper/Platform.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function __invoke(): array; 16 | } 17 | -------------------------------------------------------------------------------- /src/Connector/Command.php: -------------------------------------------------------------------------------- 1 | $errorEvents 15 | */ 16 | public function sendErrorToScout(array $errorEvents): void; 17 | } 18 | -------------------------------------------------------------------------------- /stub/scoutapm.stub.php: -------------------------------------------------------------------------------- 1 | */ 8 | function scoutapm_get_calls() : array {} 9 | function scoutapm_enable_instrumentation(bool $enabled): void {} 10 | /** @return list */ 11 | function scoutapm_list_instrumented_functions(): array {} 12 | -------------------------------------------------------------------------------- /src/Extension/ExtensionCapabilities.php: -------------------------------------------------------------------------------- 1 | */ 12 | public function getCalls(): array; 13 | 14 | public function clearRecordedCalls(): void; 15 | 16 | public function version(): ?Version; 17 | } 18 | 19 | class_alias(ExtensionCapabilities::class, ExtentionCapabilities::class); 20 | -------------------------------------------------------------------------------- /tests/Unit/CoreAgent/emulated-happy-path.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | EXPECTED_PARAMS="start --daemonize true --log-file /tmp/core-agent.log --log-level TRACE --config-file /tmp/core-agent-config.ini --socket /tmp/socket-path.sock" 6 | ACTUAL_PARAMS=$* 7 | 8 | if [ "$ACTUAL_PARAMS" != "$EXPECTED_PARAMS" ]; then 9 | >&2 printf "Script params did not match expectations.\n\nExpected: %s\nActual : %s\n" "$EXPECTED_PARAMS" "$ACTUAL_PARAMS" 10 | exit 1 11 | fi 12 | 13 | exit 0 14 | -------------------------------------------------------------------------------- /src/Cache/DevNullCache.php: -------------------------------------------------------------------------------- 1 | toString())); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Config/TypeCoercion/CoerceJson.php: -------------------------------------------------------------------------------- 1 | toString() 19 | )); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Unit/Events/Request/RequestIdTest.php: -------------------------------------------------------------------------------- 1 | toString())); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Connector/Connector.php: -------------------------------------------------------------------------------- 1 | spanId = $spanId; 19 | } 20 | 21 | /** @throws Exception */ 22 | public static function new(): self 23 | { 24 | return new self(Uuid::uuid4()); 25 | } 26 | 27 | public function toString(): string 28 | { 29 | return $this->spanId->toString(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Unit/Helper/ComposerPackagesCheckTest.php: -------------------------------------------------------------------------------- 1 | toString(), 20 | $previous->getMessage() 21 | )); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/check-memory-leaks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | cd "$(dirname "$0")" 6 | 7 | # Run it once first to allow the agent to be downloaded & executed to avoid skewing results 8 | killall -q core-agent || true 9 | rm -Rf /tmp/scout* 10 | RUN_COUNT=1 php isolated-memory-test.php > /dev/null 11 | 12 | SINGLE=$(RUN_COUNT=1 php isolated-memory-test.php) 13 | echo "Single execution used: $SINGLE bytes" 14 | 15 | MULTIPLE=$(RUN_COUNT=1000 php isolated-memory-test.php) 16 | echo "1000 executions used: $MULTIPLE bytes" 17 | 18 | if [ "$SINGLE" = "$MULTIPLE" ]; then 19 | echo "No memory leak detected" 20 | exit 0 21 | else 22 | echo "Potential memory leak!" 23 | exit 1 24 | fi 25 | -------------------------------------------------------------------------------- /src/Events/Request/RequestId.php: -------------------------------------------------------------------------------- 1 | requestId = $requestId; 19 | } 20 | 21 | /** @throws Exception */ 22 | public static function new(): self 23 | { 24 | return new self(Uuid::uuid4()); 25 | } 26 | 27 | public function toString(): string 28 | { 29 | return $this->requestId->toString(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Helper/DetermineHostname/DetermineHostnameWithConfigOverride.php: -------------------------------------------------------------------------------- 1 | config = $config; 21 | } 22 | 23 | public function __invoke(): string 24 | { 25 | return (string) ($this->config->get(ConfigKey::HOSTNAME) ?? gethostname()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Helper/MemoryUsage.php: -------------------------------------------------------------------------------- 1 | bytesUsed = memory_get_usage(false); 19 | } 20 | 21 | public static function record(): self 22 | { 23 | return new self(); 24 | } 25 | 26 | public function usedDifferenceInMegabytes(MemoryUsage $comparedTo): float 27 | { 28 | return ($this->bytesUsed - $comparedTo->bytesUsed) / self::BYTES_IN_A_MB; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Extension/DoNotInvokeAnyExtensionCapabilities.php: -------------------------------------------------------------------------------- 1 | */ 10 | public function getCalls(): array 11 | { 12 | // Intentionally, there are no calls 13 | return []; 14 | } 15 | 16 | public function clearRecordedCalls(): void 17 | { 18 | // Intentially no-op, don't invoke the extension 19 | } 20 | 21 | public function version(): ?Version 22 | { 23 | // Extension is ignored/doesn't exist, so no version to return 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Unit/Config/Source/DefaultSourceTest.php: -------------------------------------------------------------------------------- 1 | hasKey('api_version')); 17 | self::assertFalse($defaults->hasKey('notAValue')); 18 | } 19 | 20 | public function testGet(): void 21 | { 22 | $defaults = new DefaultSource(); 23 | self::assertSame('1.0', $defaults->get('api_version')); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/fixtures/laravel11-app-with-exceptions.php: -------------------------------------------------------------------------------- 1 | withRouting( 9 | web: __DIR__.'/../routes/web.php', 10 | commands: __DIR__.'/../routes/console.php', 11 | health: '/up', 12 | ) 13 | ->withMiddleware(function (Middleware $middleware) { 14 | // 15 | }) 16 | ->withExceptions(function (Exceptions $exceptions) { 17 | $exceptions->reportable(function (Throwable $e) { 18 | app()->make(\Scoutapm\ScoutApmAgent::class)->recordThrowable($e); 19 | }); 20 | })->create(); 21 | -------------------------------------------------------------------------------- /src/Config/TypeCoercion/CoerceBoolean.php: -------------------------------------------------------------------------------- 1 | etcAlpineReleasePath = $alpinePath; 21 | } 22 | 23 | /** @internal */ 24 | public function detect(): string 25 | { 26 | if (file_exists($this->etcAlpineReleasePath)) { 27 | return 'musl'; 28 | } 29 | 30 | return 'gnu'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Config/IgnoredEndpoints.php: -------------------------------------------------------------------------------- 1 | $ignoredPaths */ 16 | public function __construct(array $ignoredPaths) 17 | { 18 | $this->ignoredPaths = $ignoredPaths; 19 | } 20 | 21 | public function ignored(string $url): bool 22 | { 23 | foreach ($this->ignoredPaths as $ignore) { 24 | if (strpos($url, $ignore) === 0) { 25 | return true; 26 | } 27 | } 28 | 29 | // None Matched 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Helper/RecursivelyCountSpans.php: -------------------------------------------------------------------------------- 1 | collectedSpans(); 26 | }, 27 | 0 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Unit/Config/Source/NullSourceTest.php: -------------------------------------------------------------------------------- 1 | hasKey('apiVersion')); 17 | self::assertTrue($defaults->hasKey('notAValue')); 18 | } 19 | 20 | public function testGet(): void 21 | { 22 | $defaults = new NullSource(); 23 | self::assertNull($defaults->get('apiVersion')); 24 | self::assertNull($defaults->get('weirdThing')); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/test-socket-server/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::net::{SocketAddrV4, Ipv4Addr, TcpListener}; 2 | use std::io::Error; 3 | use std::env; 4 | 5 | fn main() -> Result<(), Error> { 6 | let args: Vec = env::args().collect(); 7 | if args.len() != 2 { 8 | panic!("No port parameter - provide a numeric port"); 9 | } 10 | let port: u16 = args[1].parse().unwrap(); 11 | 12 | let loopback = Ipv4Addr::new(127, 0, 0, 1); 13 | let socket = SocketAddrV4::new(loopback, port); 14 | let listener = TcpListener::bind(socket)?; 15 | let port = listener.local_addr()?; 16 | println!("Listening on {}, access this port to end the program", port); 17 | let (_tcp_stream, addr) = listener.accept()?; //block until requested 18 | println!("Connection received! {:?} - exiting", addr); 19 | Ok(()) 20 | } 21 | -------------------------------------------------------------------------------- /tests/Unit/Events/RegisterMessageTest.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'app' => 'app name', 19 | 'key' => 'app key', 20 | 'language' => 'php', 21 | 'api_version' => 'api version', 22 | ], 23 | ], 24 | (new RegisterMessage('app name', 'app key', 'api version'))->jsonSerialize() 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Laravel/Middleware/IgnoredEndpoints.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 19 | } 20 | 21 | /** @return mixed */ 22 | public function handle(Request $request, Closure $next) 23 | { 24 | // Check if the request path we're handling is configured to be 25 | // ignored, and if so, mark it as such. 26 | if ($this->agent->ignored('/' . $request->path())) { 27 | $this->agent->ignore('/' . $request->path()); 28 | } 29 | 30 | return $next($request); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Laravel/Database/QueryListener.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 20 | } 21 | 22 | public function __invoke(QueryExecuted $query): void 23 | { 24 | $startingTime = microtime(true) - ($query->time / 1000); 25 | 26 | $span = $this->agent->startSpan('SQL/Query', $startingTime, true); 27 | 28 | if ($span === null) { 29 | return; 30 | } 31 | 32 | $span->tag('db.statement', $query->sql); 33 | $this->agent->stopSpan(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | src 14 | tests 15 | 16 | tests/psalm-stubs.php 17 | src/Cache 18 | 19 | 20 | 21 | 22 | 23 | src/Extension/ExtensionCapabilities.php 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/Unit/Helper/MemoryUsageTest.php: -------------------------------------------------------------------------------- 1 | usedDifferenceInMegabytes($usageBefore) 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Unit/Config/Source/UserSettingsSourceTest.php: -------------------------------------------------------------------------------- 1 | hasKey('foo')); 17 | 18 | $config->set('foo', 'bar'); 19 | 20 | self::assertTrue($config->hasKey('foo')); 21 | } 22 | 23 | public function testGet(): void 24 | { 25 | $config = new UserSettingsSource(); 26 | self::assertNull($config->get('foo')); 27 | 28 | $config->set('foo', 'bar'); 29 | 30 | self::assertSame('bar', $config->get('foo')); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Unit/Helper/LocateFileOrFolderTest.php: -------------------------------------------------------------------------------- 1 | __invoke('composer.json', 0); 18 | self::assertSame( 19 | realpath(__DIR__ . '/../../../'), 20 | $composerLocation 21 | ); 22 | } 23 | 24 | public function testDefaultNumberOfLevelsSkipsComposerJson(): void 25 | { 26 | self::assertNull((new LocateFileOrFolderUsingFilesystem())->__invoke('composer.json')); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Unit/Helper/DetermineHostname/DetermineHostnameWithConfigOverrideTest.php: -------------------------------------------------------------------------------- 1 | 'www.myspace.com'])))()); 19 | } 20 | 21 | public function testHostnameUsesSystemHostnameWhenNoConfiguration(): void 22 | { 23 | self::assertSame(gethostname(), (new DetermineHostnameWithConfigOverride(Config::fromArray([])))()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Unit/Config/ConfigKeyTest.php: -------------------------------------------------------------------------------- 1 | '', 23 | ConfigKey::APPLICATION_NAME => 'Just the App Name', 24 | ], 25 | ConfigKey::filterSecretsFromConfigArray([ 26 | ConfigKey::APPLICATION_KEY => 'this is a secret', 27 | ConfigKey::APPLICATION_NAME => 'Just the App Name', 28 | ]) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Config/Source/UserSettingsSource.php: -------------------------------------------------------------------------------- 1 | */ 20 | private $config; 21 | 22 | public function __construct() 23 | { 24 | $this->config = []; 25 | } 26 | 27 | public function hasKey(string $key): bool 28 | { 29 | return array_key_exists($key, $this->config); 30 | } 31 | 32 | /** @inheritDoc */ 33 | public function get(string $key) 34 | { 35 | return $this->config[$key] ?? null; 36 | } 37 | 38 | /** @param mixed $value */ 39 | public function set(string $key, $value): void 40 | { 41 | $this->config[$key] = $value; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Unit/Config/TypeCoercion/CoerceJsonTest.php: -------------------------------------------------------------------------------- 1 | 1], 18 | $c->coerce('{"foo": 1}') 19 | ); 20 | } 21 | 22 | /** 23 | * Return null for any invalid JSON 24 | */ 25 | public function testInvalidJSON(): void 26 | { 27 | $c = new CoerceJson(); 28 | // @todo add a data provider for more invalid json strings 29 | self::assertNull($c->coerce('foo: 1}')); 30 | } 31 | 32 | public function testIgnoresNonString(): void 33 | { 34 | $c = new CoerceJson(); 35 | self::assertSame(10, $c->coerce(10)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Unit/Errors/ScoutClient/CompressPayloadTest.php: -------------------------------------------------------------------------------- 1 | reportable(function (Throwable $e) { 38 | $this->container->make(\Scoutapm\ScoutApmAgent::class)->recordThrowable($e); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/symfony-version-compatibility.php: -------------------------------------------------------------------------------- 1 | > 14 | */ 15 | protected $dontReport = [ 16 | // 17 | ]; 18 | 19 | /** 20 | * A list of the inputs that are never flashed for validation exceptions. 21 | * 22 | * @var array 23 | */ 24 | protected $dontFlash = [ 25 | 'current_password', 26 | 'password', 27 | 'password_confirmation', 28 | ]; 29 | 30 | /** 31 | * Register the exception handling callbacks for the application. 32 | * 33 | * @return void 34 | */ 35 | public function register() 36 | { 37 | $this->reportable(function (Throwable $e) { 38 | $this->container->make(\Scoutapm\ScoutApmAgent::class)->recordThrowable($e); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Helper/Superglobals/Superglobals.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function session(): array; 16 | 17 | /** 18 | * @internal This is not covered by BC promise 19 | * 20 | * @return array 21 | */ 22 | public function request(): array; 23 | 24 | /** 25 | * @internal This is not covered by BC promise 26 | * 27 | * @return array 28 | */ 29 | public function env(): array; 30 | 31 | /** 32 | * @internal This is not covered by BC promise 33 | * 34 | * @return array 35 | */ 36 | public function server(): array; 37 | 38 | /** 39 | * @internal This is not covered by BC promise 40 | * 41 | * @return list 42 | */ 43 | public function argv(): array; 44 | } 45 | -------------------------------------------------------------------------------- /src/Laravel/Middleware/MiddlewareInstrument.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 23 | $this->logger = $logger; 24 | } 25 | 26 | /** @return mixed */ 27 | public function handle(Request $request, Closure $next) 28 | { 29 | $this->logger->debug('Handle MiddlewareInstrument'); 30 | 31 | return $this->agent->instrument( 32 | 'Middleware', 33 | 'all', 34 | /** @return mixed */ 35 | static function () use ($request, $next) { 36 | return $next($request); 37 | } 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Unit/Config/Source/EnvSourceTest.php: -------------------------------------------------------------------------------- 1 | hasKey('test_case_foo')); 19 | 20 | putenv('SCOUT_TEST_CASE_FOO=thevalue'); 21 | 22 | self::assertTrue($config->hasKey('test_case_foo')); 23 | 24 | // Clean up the var 25 | putenv('SCOUT_TEST_CASE_FOO'); 26 | } 27 | 28 | public function testGet(): void 29 | { 30 | $config = new EnvSource(); 31 | self::assertNull($config->get('test_case_bar')); 32 | 33 | putenv('SCOUT_TEST_CASE_BAR=thevalue'); 34 | 35 | self::assertSame('thevalue', $config->get('test_case_bar')); 36 | 37 | // Clean up the var 38 | putenv('SCOUT_TEST_CASE_BAR'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Config/Source/EnvSource.php: -------------------------------------------------------------------------------- 1 | envVarName($key)) !== false; 25 | } 26 | 27 | /** @inheritDoc */ 28 | public function get(string $key) 29 | { 30 | $value = getenv($this->envVarName($key)); 31 | 32 | // Make sure this returns null when not found, instead of getEnv's false. 33 | if ($value === false) { 34 | $value = null; 35 | } 36 | 37 | return $value; 38 | } 39 | 40 | private function envVarName(string $key): string 41 | { 42 | return self::SCOUT_PREFIX . strtoupper($key); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Events/RegisterMessage.php: -------------------------------------------------------------------------------- 1 | appName = $appName; 24 | $this->appKey = $appKey; 25 | $this->apiVersion = $apiVersion; 26 | } 27 | 28 | public function cleanUp(): void 29 | { 30 | } 31 | 32 | /** @return array> */ 33 | public function jsonSerialize(): array 34 | { 35 | return [ 36 | 'Register' => [ 37 | 'app' => $this->appName, 38 | 'key' => $this->appKey, 39 | 'language' => 'php', 40 | 'api_version' => $this->apiVersion, 41 | ], 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Scout APM 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 | -------------------------------------------------------------------------------- /tests/Unit/Extension/DoNotInvokeAnyExtensionCapabilitiesTest.php: -------------------------------------------------------------------------------- 1 | getCalls()); 19 | $capabilities->clearRecordedCalls(); 20 | self::assertEquals([], $capabilities->getCalls()); 21 | } 22 | 23 | public function testGetCalls(): void 24 | { 25 | file_get_contents(__FILE__); 26 | self::assertEquals([], (new DoNotInvokeAnyExtensionCapabilities())->getCalls()); 27 | } 28 | 29 | public function testVersion(): void 30 | { 31 | self::assertNull((new DoNotInvokeAnyExtensionCapabilities())->version()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Laravel/config/scout_apm.php: -------------------------------------------------------------------------------- 1 | getTag()); 24 | self::assertSame('v', $tag->getValue()); 25 | } 26 | 27 | /** @throws Exception */ 28 | public function testJsonSerializes(): void 29 | { 30 | $requestId = RequestId::new(); 31 | $serialized = (new TagRequest('t', 'v', $requestId))->jsonSerialize(); 32 | 33 | self::assertArrayHasKey('TagRequest', $serialized[0]); 34 | 35 | $data = $serialized[0]['TagRequest']; 36 | self::assertSame('t', $data['tag']); 37 | self::assertSame('v', $data['value']); 38 | self::assertSame($requestId->toString(), $data['request_id']); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Laravel/Facades/ScoutApm.php: -------------------------------------------------------------------------------- 1 | ignored('/health')); 22 | self::assertTrue($ignoredEndpoints->ignored('/status')); 23 | 24 | // Prefix Match 25 | self::assertTrue($ignoredEndpoints->ignored('/health/database')); 26 | self::assertTrue($ignoredEndpoints->ignored('/status/time')); 27 | 28 | // No Match 29 | self::assertFalse($ignoredEndpoints->ignored('/signup')); 30 | 31 | // Not-prefix doesn't Match 32 | self::assertFalse($ignoredEndpoints->ignored('/hero/1/health')); 33 | } 34 | 35 | public function testWorksWithNullIgnoreSetting(): void 36 | { 37 | // No Match 38 | self::assertFalse((new IgnoredEndpoints([]))->ignored('/signup')); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Laravel/Middleware/SendRequestToScout.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 24 | $this->logger = $logger; 25 | } 26 | 27 | /** 28 | * @psalm-param Closure(Request):mixed $next 29 | * 30 | * @return mixed 31 | */ 32 | public function handle(Request $request, Closure $next) 33 | { 34 | $this->agent->connect(); 35 | 36 | /** @var mixed $response */ 37 | $response = $next($request); 38 | 39 | try { 40 | $this->agent->send(); 41 | $this->logger->debug('SendRequestToScout succeeded'); 42 | } catch (Throwable $e) { 43 | $this->logger->debug('SendRequestToScout failed: ' . $e->getMessage(), ['exception' => $e]); 44 | } 45 | 46 | return $response; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Unit/Helper/BacktraceTest.php: -------------------------------------------------------------------------------- 1 | setAccessible(true); 28 | $children = $childrenProperty->getValue($commandWithChildren); 29 | 30 | Assert::isArray($children); 31 | Assert::allIsInstanceOf($children, Command::class); 32 | 33 | return $children; 34 | } 35 | 36 | public static function firstChildForCommand(CommandWithChildren $commandWithChildren): Command 37 | { 38 | $children = self::childrenForCommand($commandWithChildren); 39 | 40 | Assert::greaterThanEq(count($children), 1); 41 | 42 | return reset($children); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/DependencyInjection/ScoutApmExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration(new Configuration(), $configs); 27 | 28 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 29 | $loader->load('scoutapm.xml'); 30 | 31 | $definition = $container->getDefinition(ScoutApmAgent::class); 32 | $definition->replaceArgument( 33 | '$agentConfiguration', 34 | array_key_exists('scoutapm', $config) ? $config['scoutapm'] : [] 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/ScoutApmAgentFactory.php: -------------------------------------------------------------------------------- 1 | $agentConfiguration */ 22 | public static function createAgent( 23 | LoggerInterface $logger, 24 | ?CacheInterface $cache, 25 | ?Connector $connector, 26 | ?ExtensionCapabilities $extensionCapabilities, 27 | array $agentConfiguration 28 | ): ScoutApmAgent { 29 | return Agent::fromConfig( 30 | Config::fromArray(array_merge( 31 | [ 32 | Config\ConfigKey::FRAMEWORK => 'Symfony', 33 | Config\ConfigKey::FRAMEWORK_VERSION => Kernel::VERSION, 34 | ], 35 | array_filter($agentConfiguration) 36 | )), 37 | $logger, 38 | $cache, 39 | $connector, 40 | $extensionCapabilities 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Integration/MessageCapturingConnectorDelegator.php: -------------------------------------------------------------------------------- 1 | >> */ 14 | final class MessageCapturingConnectorDelegator implements Connector 15 | { 16 | /** @psalm-var UnserializedCapturedMessagesList */ 17 | public $sentMessages = []; 18 | 19 | /** @var Connector */ 20 | private $delegate; 21 | 22 | public function __construct(Connector $delegate) 23 | { 24 | $this->delegate = $delegate; 25 | } 26 | 27 | public function connect(): void 28 | { 29 | $this->delegate->connect(); 30 | } 31 | 32 | public function connected(): bool 33 | { 34 | return $this->delegate->connected(); 35 | } 36 | 37 | public function sendCommand(Command $message): string 38 | { 39 | /** @psalm-suppress MixedPropertyTypeCoercion */ 40 | $this->sentMessages[] = json_decode(json_encode($message), true); 41 | 42 | return $this->delegate->sendCommand($message); 43 | } 44 | 45 | public function shutdown(): void 46 | { 47 | $this->delegate->shutdown(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Unit/Events/Tag/TagSpanTest.php: -------------------------------------------------------------------------------- 1 | getTag()); 25 | self::assertSame('v', $tag->getValue()); 26 | } 27 | 28 | /** @throws Exception */ 29 | public function testJsonSerializes(): void 30 | { 31 | $requestId = RequestId::new(); 32 | $spanId = SpanId::new(); 33 | 34 | $serialized = (new TagSpan('t', 'v', $requestId, $spanId))->jsonSerialize(); 35 | 36 | self::assertArrayHasKey('TagSpan', $serialized[0]); 37 | 38 | $data = $serialized[0]['TagSpan']; 39 | self::assertSame('t', $data['tag']); 40 | self::assertSame('v', $data['value']); 41 | self::assertSame($requestId->toString(), $data['request_id']); 42 | self::assertSame($spanId->toString(), $data['span_id']); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/fixtures/laravel5-6-exception-handler.php: -------------------------------------------------------------------------------- 1 | container->make(\Scoutapm\ScoutApmAgent::class)->recordThrowable($exception); 38 | parent::report($exception); 39 | } 40 | 41 | /** 42 | * Render an exception into an HTTP response. 43 | * 44 | * @param \Illuminate\Http\Request $request 45 | * @param \Exception $exception 46 | * @return \Illuminate\Http\Response 47 | */ 48 | public function render($request, Exception $exception) 49 | { 50 | return parent::render($request, $exception); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/CoreAgent/Verifier.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 24 | $this->coreAgentDownloadPath = $coreAgentDownloadPath; 25 | } 26 | 27 | public function verify(): ?string 28 | { 29 | // Check for a well formed manifest 30 | $manifest = new Manifest($this->coreAgentDownloadPath . '/manifest.json', $this->logger); 31 | if (! $manifest->isValid()) { 32 | $this->logger->debug('Core Agent verification failed: Manifest is not valid.'); 33 | 34 | return null; 35 | } 36 | 37 | // Check that the hash matches 38 | $binPath = $this->coreAgentDownloadPath . '/' . $manifest->binaryName(); 39 | if (hash_equals($manifest->hashOfBinary(), hash_file('sha256', $binPath))) { 40 | return $binPath; 41 | } 42 | 43 | $this->logger->debug('Core Agent verification failed: SHA mismatch.'); 44 | 45 | return null; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Integration/CheckScoutApmKeyListener.php: -------------------------------------------------------------------------------- 1 | hasOutput) { 28 | return; 29 | } 30 | 31 | $scoutApmKey = getenv('SCOUT_APM_KEY'); 32 | 33 | if ($scoutApmKey === false || $scoutApmKey === '') { 34 | echo "Running without SCOUT_APM_KEY configured, some tests will be skipped.\n\n"; 35 | $this->hasOutput = true; 36 | 37 | return; 38 | } 39 | 40 | echo sprintf( 41 | "Running with SCOUT_APM_KEY set %s%s\n\n", 42 | substr($scoutApmKey, 0, self::SHOW_CHARACTERS_OF_KEY), 43 | str_repeat('*', strlen($scoutApmKey) - self::SHOW_CHARACTERS_OF_KEY) 44 | ); 45 | $this->hasOutput = true; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Dev Guide 2 | 3 | ## Setup 4 | 5 | ```bash 6 | composer install 7 | ``` 8 | 9 | ## Running tests 10 | 11 | ```bash 12 | vendor/bin/phpunit 13 | ``` 14 | 15 | ## Writing Code 16 | 17 | ### Checking for static analysis issues 18 | 19 | ```bash 20 | vendor/bin/psalm 21 | ``` 22 | 23 | ### Checking coding standards are met 24 | 25 | ```bash 26 | vendor/bin/phpcs 27 | ``` 28 | 29 | ### Fixing coding standards automatically 30 | 31 | We have an automated style fixer called PHP Code Sniffer. Style is checked on TravisCI as well. 32 | 33 | ```bash 34 | vendor/bin/phpcbf 35 | ``` 36 | 37 | ## Automated releases 38 | 39 | This project makes use of the `laminas/automatic-releases` GitHub actions for PHP projects to automate the release 40 | process. In summary, this means: 41 | 42 | - Trunk branches are made for each minor, e.g. `8.1.x` 43 | - Each pull request is assigned to a milestone 44 | - When a milestone is closed, the trunk branch is tagged and a release created with automatic changelogs based on the 45 | pull requests assigned to the milestone. 46 | - If the milestone is for a patch release, a pull request is made to also merge the change into the latest trunk 47 | - When a minor or major release is made, new trunk branches are made and automatically switched to (e.g. `8.2.x` or `9.0.x`) 48 | 49 | For full details, please see [`laminas/automatic-releases`](https://github.com/laminas/automatic-releases/). 50 | -------------------------------------------------------------------------------- /src/Laravel/Router/DetermineLaravelControllerName.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 24 | $this->router = $router; 25 | } 26 | 27 | public function __invoke(Request $request): string 28 | { 29 | $name = 'unknown'; 30 | 31 | try { 32 | $route = $this->router->current(); 33 | if ($route !== null) { 34 | /** @var mixed $name */ 35 | $name = $route->action['controller'] ?? $route->uri(); 36 | Assert::stringNotEmpty($name); 37 | } 38 | } catch (Throwable $e) { 39 | $this->logger->debug( 40 | 'Exception obtaining name of Laravel endpoint: ' . $e->getMessage(), 41 | ['exception' => $e] 42 | ); 43 | } 44 | 45 | return 'Controller/' . $name; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Unit/Laravel/View/Engine/EngineImplementationWithGetCompilerMethod.php: -------------------------------------------------------------------------------- 1 | */ 13 | protected $lastCompiled = []; 14 | 15 | /** @inheritDoc */ 16 | public function get($path, array $data = []) 17 | { 18 | return ''; 19 | } 20 | 21 | /** @param list $newValue */ 22 | public function setLastCompiled(array $newValue): void 23 | { 24 | $this->lastCompiled = $newValue; 25 | } 26 | 27 | public function getCompiler(): CompilerInterface 28 | { 29 | return new class implements CompilerInterface { 30 | /** @inheritDoc */ 31 | public function getCompiledPath($path): string 32 | { 33 | return ''; 34 | } 35 | 36 | /** @inheritDoc */ 37 | public function isExpired($path): bool 38 | { 39 | return true; 40 | } 41 | 42 | /** @inheritDoc */ 43 | public function compile($path): void 44 | { 45 | } 46 | }; 47 | } 48 | 49 | public function forgetCompiledOrNotExpired(): void 50 | { 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/fixtures/laravel7-exception-handler.php: -------------------------------------------------------------------------------- 1 | container->make(\Scoutapm\ScoutApmAgent::class)->recordThrowable($exception); 40 | parent::report($exception); 41 | } 42 | 43 | /** 44 | * Render an exception into an HTTP response. 45 | * 46 | * @param \Illuminate\Http\Request $request 47 | * @param \Throwable $exception 48 | * @return \Symfony\Component\HttpFoundation\Response 49 | * 50 | * @throws \Throwable 51 | */ 52 | public function render($request, Throwable $exception) 53 | { 54 | return parent::render($request, $exception); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Helper/LocateFileOrFolder/LocateFileOrFolderUsingFilesystem.php: -------------------------------------------------------------------------------- 1 | 0) { 26 | $dir = dirname(__DIR__, $skipLevels); 27 | } 28 | 29 | $rootOrHome = '/'; 30 | 31 | while (dirname($dir) !== $dir && $dir !== $rootOrHome) { 32 | $fileOrFolderAttempted = $dir . '/' . $fileOrFolder; 33 | if (file_exists($fileOrFolderAttempted) && is_readable($fileOrFolderAttempted)) { 34 | $realPath = realpath($dir); 35 | 36 | if ($realPath === false) { 37 | return null; 38 | } 39 | 40 | return $realPath; 41 | } 42 | 43 | $dir = dirname($dir); 44 | } 45 | 46 | return null; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | '@', 5 | 'log' => \Psr\Log\LoggerInterface::class, 6 | 'view' => \Illuminate\View\Factory::class, 7 | 'events' => \Illuminate\Contracts\Events\Dispatcher::class, 8 | 'db' => \Illuminate\Database\DatabaseManager::class, 9 | 'view.engine.resolver' => \Illuminate\View\Engines\EngineResolver::class, 10 | ])); 11 | override(\Illuminate\Foundation\Application::make(0), map([ 12 | '' => '@', 13 | 'view' => \Illuminate\View\Factory::class, 14 | ])); 15 | override(\Illuminate\Contracts\Foundation\Application::make(0), map([ 16 | '' => '@', 17 | 'log' => \Psr\Log\LoggerInterface::class, 18 | 'view' => \Illuminate\View\Factory::class, 19 | 'events' => \Illuminate\Contracts\Events\Dispatcher::class, 20 | 'db' => \Illuminate\Database\DatabaseManager::class, 21 | 'view.engine.resolver' => \Illuminate\View\Engines\EngineResolver::class, 22 | ])); 23 | override(\Laravel\Lumen\Application::make(0), map([ 24 | '' => '@', 25 | 'log' => \Psr\Log\LoggerInterface::class, 26 | 'view' => \Illuminate\View\Factory::class, 27 | 'events' => \Illuminate\Contracts\Events\Dispatcher::class, 28 | 'db' => \Illuminate\Database\DatabaseManager::class, 29 | 'view.engine.resolver' => \Illuminate\View\Engines\EngineResolver::class, 30 | ])); 31 | } 32 | -------------------------------------------------------------------------------- /src/Laravel/Router/DetermineDingoControllerName.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 24 | $this->router = $router; 25 | } 26 | 27 | public function __invoke(Request $request): string 28 | { 29 | $name = 'unknown'; 30 | 31 | try { 32 | $route = $this->router->current(); 33 | /** @psalm-suppress RedundantConditionGivenDocblockType Docblock says no null, but it CAN contain null */ 34 | if ($route !== null) { 35 | /** @var mixed $name */ 36 | $name = $route->action['controller'] ?? $route->uri(); 37 | Assert::stringNotEmpty($name); 38 | } 39 | } catch (Throwable $e) { 40 | $this->logger->debug( 41 | 'Exception obtaining name of Dingo endpoint: ' . $e->getMessage(), 42 | ['exception' => $e] 43 | ); 44 | } 45 | 46 | return 'Controller/' . $name; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Laravel/Queue/JobQueueListener.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 23 | } 24 | 25 | public function startNewRequestForJob(): void 26 | { 27 | $this->agent->startNewRequest(); 28 | } 29 | 30 | /** @throws Exception */ 31 | public function startSpanForJob(JobProcessing $jobProcessingEvent): void 32 | { 33 | $jobName = class_basename($jobProcessingEvent->job->resolveName()); 34 | 35 | if ($this->agent->ignored($jobName)) { 36 | $this->agent->ignore($jobName); 37 | } 38 | 39 | /** @noinspection UnusedFunctionResultInspection */ 40 | $this->agent->startSpan(sprintf( 41 | '%s/%s', 42 | SpanReference::INSTRUMENT_JOB, 43 | $jobName 44 | )); 45 | } 46 | 47 | /** @throws Exception */ 48 | public function stopSpanForJob(): void 49 | { 50 | $this->agent->stopSpan(); 51 | } 52 | 53 | /** @throws Exception */ 54 | public function sendRequestForJob(): void 55 | { 56 | $this->agent->connect(); 57 | $this->agent->send(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Events/Span/SpanReference.php: -------------------------------------------------------------------------------- 1 | realSpan = $realSpan; 23 | } 24 | 25 | public static function fromSpan(Span $realSpan): self 26 | { 27 | return new self($realSpan); 28 | } 29 | 30 | public function updateName(string $newName): void 31 | { 32 | $this->realSpan->updateName($newName); 33 | } 34 | 35 | /** @param mixed $value */ 36 | public function tag(string $tag, $value): void 37 | { 38 | $this->realSpan->tag($tag, $value); 39 | } 40 | 41 | public function getName(): string 42 | { 43 | return $this->realSpan->getName(); 44 | } 45 | 46 | public function getStartTime(): ?string 47 | { 48 | return $this->realSpan->getStartTime(); 49 | } 50 | 51 | public function getStopTime(): ?string 52 | { 53 | return $this->realSpan->getStopTime(); 54 | } 55 | 56 | public function duration(): ?float 57 | { 58 | return $this->realSpan->duration(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Events/Tag/TagRequest.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | public function jsonSerialize(): array 35 | { 36 | // Format the timestamp 37 | $timestamp = DateTime::createFromFormat('U.u', sprintf('%.6F', $this->timestamp)); 38 | $timestamp->setTimeZone(new DateTimeZone('UTC')); 39 | $timestamp = $timestamp->format('Y-m-d\TH:i:s.u\Z'); 40 | 41 | return [ 42 | [ 43 | 'TagRequest' => [ 44 | 'request_id' => $this->requestId->toString(), 45 | 'tag' => $this->tag, 46 | 'value' => $this->value, 47 | 'timestamp' => $timestamp, 48 | ], 49 | ], 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Unit/Config/TypeCoercion/CoerceBooleanTest.php: -------------------------------------------------------------------------------- 1 | coerce('t')); 19 | self::assertTrue($c->coerce('true')); 20 | self::assertTrue($c->coerce('1')); 21 | self::assertTrue($c->coerce('yes')); 22 | self::assertTrue($c->coerce('YES')); 23 | self::assertTrue($c->coerce('T')); 24 | self::assertTrue($c->coerce('TRUE')); 25 | 26 | // Falses 27 | self::assertFalse($c->coerce('f')); 28 | self::assertFalse($c->coerce('false')); 29 | self::assertFalse($c->coerce('no')); 30 | self::assertFalse($c->coerce('0')); 31 | } 32 | 33 | public function testIgnoresBooleans(): void 34 | { 35 | $c = new CoerceBoolean(); 36 | 37 | self::assertTrue($c->coerce(true)); 38 | self::assertFalse($c->coerce(false)); 39 | } 40 | 41 | public function testNullIsFalse(): void 42 | { 43 | $c = new CoerceBoolean(); 44 | 45 | self::assertFalse($c->coerce(null)); 46 | } 47 | 48 | public function testAnythingElseIsFalse(): void 49 | { 50 | $c = new CoerceBoolean(); 51 | 52 | // $c is "any object" 53 | self::assertFalse($c->coerce($c)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Errors/ErrorHandling.php: -------------------------------------------------------------------------------- 1 | recordThrowable($t); 42 | * throw $t; // or otherwise handle/log/etc. depending on requirements 43 | * } 44 | */ 45 | public function recordThrowable(Throwable $throwable): void; 46 | } 47 | -------------------------------------------------------------------------------- /tests/Unit/Helper/Superglobals/SuperglobalsArraysTest.php: -------------------------------------------------------------------------------- 1 | 'a']; 24 | $_ENV = ['b' => 'b']; 25 | $_SESSION = ['c' => 'c']; 26 | $_REQUEST = ['d' => 'd']; 27 | $GLOBALS['argv'] = ['a', 'b', 'c']; 28 | 29 | try { 30 | $superglobals = SuperglobalsArrays::fromGlobalState(); 31 | 32 | self::assertEquals(['a' => 'a'], $superglobals->server()); 33 | self::assertEquals(['b' => 'b'], $superglobals->env()); 34 | self::assertEquals(['c' => 'c'], $superglobals->session()); 35 | self::assertEquals(['d' => 'd'], $superglobals->request()); 36 | self::assertEquals(['a', 'b', 'c'], $superglobals->argv()); 37 | } finally { 38 | $_ENV = $oldEnv; 39 | $_SERVER = $oldServer; 40 | $_SESSION = $oldSession; 41 | $_REQUEST = $oldRequest; 42 | 43 | /** @psalm-suppress MixedAssignment */ 44 | $GLOBALS['argv'] = $oldArgv; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/isolated-memory-test.php: -------------------------------------------------------------------------------- 1 | 'Agent Integration Test', 24 | 'key' => $scoutApmKey, 25 | 'monitor' => true, 26 | ]); 27 | 28 | $logger = new TestLogger(); 29 | 30 | $agent = Agent::fromConfig($config, $logger); 31 | 32 | $agent->connect(); 33 | 34 | (new PotentiallyAvailableExtensionCapabilities())->clearRecordedCalls(); 35 | 36 | $tagSize = 500000; 37 | $startingMemory = memory_get_usage(); 38 | for ($i = 1; $i <= $runCount; $i++) { 39 | $agent->startNewRequest(); 40 | $span = $agent->startSpan(sprintf( 41 | '%s/%s%d', 42 | SpanReference::INSTRUMENT_JOB, 43 | 'Test Job #', 44 | $i 45 | )); 46 | 47 | assert($span !== null); 48 | 49 | $span->tag('something', str_repeat('a', $tagSize)); 50 | 51 | $agent->stopSpan(); 52 | 53 | $agent->connect(); 54 | $agent->send(); 55 | } 56 | 57 | $logger->records = []; 58 | $logger->recordsByLevel = []; 59 | 60 | $used = memory_get_usage() - $startingMemory; 61 | echo $used . "\n"; 62 | -------------------------------------------------------------------------------- /tests/Unit/Helper/FindRequestHeadersUsingServerGlobalTest.php: -------------------------------------------------------------------------------- 1 | '/path/to/public', 19 | 'Remote-Addr' => '127.0.0.1', 20 | 'Host' => 'scout-apm-test', 21 | 'User-Agent' => 'Scout APM test', 22 | 'Cookie' => 'cookie_a=null; cookie_b=null', 23 | 'Accept' => '*/*', 24 | 'X-Something-Custom' => 'Something custom', 25 | ], 26 | (new FindRequestHeadersUsingServerGlobal(new SuperglobalsArrays( 27 | [], 28 | [], 29 | [], 30 | [ 31 | 'DOCUMENT_ROOT' => '/path/to/public', 32 | 'REMOTE_ADDR' => '127.0.0.1', 33 | 'HTTP_HOST' => 'scout-apm-test', 34 | 'HTTP_USER_AGENT' => 'Scout APM test', 35 | 'HTTP_COOKIE' => 'cookie_a=null; cookie_b=null', 36 | 'HTTP_ACCEPT' => '*/*', 37 | 'HTTP_X_SOMETHING_EMPTY' => '', 38 | 'HTTP_X_SOMETHING_CUSTOM' => 'Something custom', 39 | ], 40 | [] 41 | )))->__invoke() 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: * 2 | 3 | PHP_VERSION=7.4 4 | PHP_PATH=/usr/bin/env php$(PHP_VERSION) 5 | COMPOSER_PATH=/usr/local/bin/composer 6 | OPTS= 7 | 8 | default: unit cs static-analysis ## all the things 9 | 10 | help: 11 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 12 | 13 | unit: ## run unit tests 14 | $(PHP_PATH) vendor/bin/phpunit $(OPTS) 15 | 16 | cs: ## verify code style rules 17 | $(PHP_PATH) vendor/bin/phpcs $(OPTS) 18 | 19 | cs-fix: ## auto fix code style rules 20 | $(PHP_PATH) vendor/bin/phpcbf $(OPTS) 21 | 22 | static-analysis: ## verify that no new static analysis issues were introduced 23 | $(PHP_PATH) vendor/bin/psalm --threads=1 $(OPTS) 24 | 25 | coverage: ## generate code coverage reports 26 | $(PHP_PATH) vendor/bin/phpunit --testsuite unit --coverage-html build/coverage-html --coverage-text $(OPTS) 27 | 28 | deps-install: ## Install the currently-locked set of dependencies 29 | git restore composer.lock 30 | rm -Rf vendor 31 | $(PHP_PATH) $(COMPOSER_PATH) install 32 | 33 | deps-lowest: ## Update deps to lowest 34 | $(PHP_PATH) $(COMPOSER_PATH) update --prefer-lowest --prefer-dist --no-interaction 35 | rm -Rf vendor 36 | $(PHP_PATH) $(COMPOSER_PATH) install 37 | 38 | deps-highest: ## Update deps to highest 39 | $(PHP_PATH) $(COMPOSER_PATH) update --prefer-dist --no-interaction 40 | rm -Rf vendor 41 | $(PHP_PATH) $(COMPOSER_PATH) install 42 | 43 | update-static-analysis-baseline: ## bump static analysis baseline issues, reducing set of allowed failures 44 | $(PHP_PATH) vendor/bin/psalm --update-baseline --threads=1 45 | 46 | reset-static-analysis-baseline: ## reset static analysis baseline issues to current HEAD 47 | $(PHP_PATH) vendor/bin/psalm --set-baseline=known-issues.xml --threads=1 48 | -------------------------------------------------------------------------------- /tests/Unit/Helper/FindRootPackageGitShaWithHerokuAndConfigOverrideTest.php: -------------------------------------------------------------------------------- 1 | 'abcdef']), 22 | new SuperglobalsArrays([], [], [], [], []) 23 | ))() 24 | ); 25 | } 26 | 27 | public function testFindingRootPackageGitShaFromHerokuSlugCommit(): void 28 | { 29 | self::assertSame( 30 | 'bcdef1', 31 | (new FindRootPackageGitShaWithHerokuAndConfigOverride( 32 | Config::fromArray([]), 33 | new SuperglobalsArrays([], [], ['HEROKU_SLUG_COMMIT' => 'bcdef1'], [], []) 34 | ))() 35 | ); 36 | } 37 | 38 | public function testFindingRootPackageGitShaFallbackUsingComposer(): void 39 | { 40 | self::assertSame( 41 | InstalledVersions::getRootPackage()['reference'], 42 | (new FindRootPackageGitShaWithHerokuAndConfigOverride( 43 | Config::fromArray([]), 44 | new SuperglobalsArrays([], [], [], [], []) 45 | ))() 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Middleware/ScoutApmMiddleware.php: -------------------------------------------------------------------------------- 1 | scoutApmAgent = $scoutApmAgent; 25 | $this->logger = $logger; 26 | } 27 | 28 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 29 | { 30 | $this->scoutApmAgent->connect(); 31 | 32 | try { 33 | $response = $this->scoutApmAgent->webTransaction( 34 | $request->getUri()->getPath(), 35 | static function () use ($request, $handler) { 36 | return $handler->handle($request); 37 | } 38 | ); 39 | } catch (Throwable $exception) { 40 | $this->scoutApmAgent->tagRequest('error', 'true'); 41 | 42 | $this->scoutApmAgent->recordThrowable($exception); 43 | 44 | throw $exception; 45 | } finally { 46 | try { 47 | $this->scoutApmAgent->send(); 48 | } catch (Throwable $e) { 49 | $this->logger->debug('PSR-15 Send to Scout failed: ' . $e->getMessage(), ['exception' => $e]); 50 | } 51 | } 52 | 53 | return $response; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/EventListener/DoctrineSqlLogger.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 24 | } 25 | 26 | public function registerWith(Connection $connection): void 27 | { 28 | $connectionConfiguration = $connection->getConfiguration(); 29 | 30 | $currentLogger = $connectionConfiguration->getSQLLogger(); 31 | 32 | if ($currentLogger === null) { 33 | $connectionConfiguration->setSQLLogger($this); 34 | 35 | return; 36 | } 37 | 38 | $connectionConfiguration->setSQLLogger(new LoggerChain([ 39 | $currentLogger, 40 | $this, 41 | ])); 42 | } 43 | 44 | /** @inheritDoc */ 45 | public function startQuery($sql, ?array $params = null, ?array $types = null) 46 | { 47 | $this->currentSpan = $this->agent->startSpan('SQL/Query', null, true); 48 | 49 | if ($this->currentSpan === null) { 50 | return; 51 | } 52 | 53 | $this->currentSpan->tag('db.statement', $sql); 54 | } 55 | 56 | /** @inheritDoc */ 57 | public function stopQuery() 58 | { 59 | if ($this->currentSpan === null) { 60 | return; 61 | } 62 | 63 | $this->agent->stopSpan(); 64 | $this->currentSpan = null; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Unit/Helper/TimerTest.php: -------------------------------------------------------------------------------- 1 | getStart()); 26 | } 27 | 28 | public function testStoppingSetsStopTime(): void 29 | { 30 | $timer = new Timer(); 31 | $timer->stop(); 32 | self::assertNotNull($timer->getStop()); 33 | } 34 | 35 | public function testStopTimeIsNullIfNotStopped(): void 36 | { 37 | $timer = new Timer(); 38 | self::assertNull($timer->getStop()); 39 | } 40 | 41 | public function testTimesAreFormatted(): void 42 | { 43 | $timer = new Timer(); 44 | $timer->stop(); 45 | 46 | self::assertRegExp(self::DATE_FORMAT_VALIDATION_REGEX, (string) $timer->getStart()); 47 | self::assertRegExp(self::DATE_FORMAT_VALIDATION_REGEX, (string) $timer->getStop()); 48 | } 49 | 50 | public function testDurationIsNullIfNotStopped(): void 51 | { 52 | $timer = new Timer(); 53 | self::assertNull($timer->duration()); 54 | } 55 | 56 | public function testDurationIsPositiveIfStopped(): void 57 | { 58 | $timer = new Timer(); 59 | usleep(1); 60 | $timer->stop(); 61 | self::assertTrue($timer->duration() > 0); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/Resources/config/scoutapm.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | null 15 | null 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /tests/isolated-error-capture-test.php: -------------------------------------------------------------------------------- 1 | 'Agent Integration Test', 31 | Config\ConfigKey::APPLICATION_KEY => $scoutApmKey, 32 | Config\ConfigKey::MONITORING_ENABLED => true, 33 | Config\ConfigKey::ERRORS_ENABLED => true, 34 | ]); 35 | 36 | $logFile = sys_get_temp_dir() . '/' . uniqid('scout-error-capture-test-', true) . '.log'; 37 | 38 | echo $logFile . "\n"; 39 | 40 | $logger = new Logger('scout-error-capture-test'); 41 | $logger->pushHandler(new StreamHandler($logFile)); 42 | 43 | $agent = Agent::fromConfig($config, $logger); 44 | 45 | $agent->connect(); 46 | 47 | $agent->tagRequest('myTag', 'myTagValue'); 48 | 49 | $_SERVER['HTTP_HOST'] = 'my-test-site'; 50 | $_SERVER['SERVER_PORT'] = '443'; 51 | $_SERVER['HTTPS'] = 'on'; 52 | $_SERVER['REQUEST_URI'] = '/path/to/my/app'; 53 | 54 | $agent->webTransaction('MyWebTransaction', static function (): void { 55 | throw new LogicException('Something went wrong'); 56 | }); 57 | -------------------------------------------------------------------------------- /src/Cache/DevNullCacheSimpleCache1.php: -------------------------------------------------------------------------------- 1 | get($key, $default); 55 | }, 56 | $keysAsArray 57 | ) 58 | ); 59 | } 60 | 61 | /** @inheritDoc */ 62 | public function setMultiple($values, $ttl = null): bool 63 | { 64 | return true; 65 | } 66 | 67 | /** @inheritDoc */ 68 | public function deleteMultiple($keys): bool 69 | { 70 | return true; 71 | } 72 | 73 | /** @inheritDoc */ 74 | public function has($key): bool 75 | { 76 | return false; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Events/Tag/Tag.php: -------------------------------------------------------------------------------- 1 | tag = $tag; 47 | $this->value = $value; 48 | $this->requestId = $requestId; 49 | $this->timestamp = $timestamp; 50 | } 51 | 52 | public function cleanUp(): void 53 | { 54 | unset($this->tag, $this->value, $this->requestId, $this->timestamp); 55 | } 56 | 57 | /** 58 | * Get the 'key' portion of this Tag 59 | */ 60 | public function getTag(): string 61 | { 62 | return $this->tag; 63 | } 64 | 65 | /** 66 | * Get the 'value' portion of this Tag 67 | */ 68 | public function getValue(): string 69 | { 70 | return $this->value; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Laravel/Console/ConsoleListener.php: -------------------------------------------------------------------------------- 1 | */ 22 | private $argv; 23 | 24 | /** @param list $argv */ 25 | public function __construct(ScoutApmAgent $agent, array $argv) 26 | { 27 | $this->agent = $agent; 28 | $this->argv = $argv; 29 | } 30 | 31 | /** @throws Exception */ 32 | public function startSpanForCommand(CommandStarting $commandStartingEvent): void 33 | { 34 | if ($commandStartingEvent->command === null) { 35 | return; 36 | } 37 | 38 | $commandName = $commandStartingEvent->command; 39 | 40 | $this->agent->startNewRequest(); 41 | 42 | if ($this->agent->ignored($commandName)) { 43 | $this->agent->ignore($commandName); 44 | } 45 | 46 | $this->agent->addContext(Tag::TAG_ARGUMENTS, implode(' ', $this->argv)); 47 | 48 | /** @noinspection UnusedFunctionResultInspection */ 49 | $this->agent->startSpan(sprintf( 50 | '%s/artisan/%s', 51 | SpanReference::INSTRUMENT_JOB, 52 | $commandName 53 | )); 54 | } 55 | 56 | /** @throws Exception */ 57 | public function stopSpanForCommand(CommandFinished $commandFinishedEvent): void 58 | { 59 | if ($commandFinishedEvent->command === null) { 60 | return; 61 | } 62 | 63 | $this->agent->stopSpan(); 64 | $this->agent->connect(); 65 | $this->agent->send(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/MongoDB/QueryTimeCollector.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 25 | } 26 | 27 | public static function register(ScoutApmAgent $agent): self 28 | { 29 | if (! extension_loaded('mongodb')) { 30 | throw new RuntimeException('Tried to register MongoDB subscriber, but mongodb extension was missing'); 31 | } 32 | 33 | $collector = new self($agent); 34 | 35 | /** @psalm-suppress UnusedFunctionCall */ 36 | addSubscriber($collector); 37 | 38 | return $collector; 39 | } 40 | 41 | public function commandFailed(CommandFailedEvent $event): void 42 | { 43 | $this->agent->stopSpan(); 44 | } 45 | 46 | public function commandStarted(CommandStartedEvent $event): void 47 | { 48 | $activeSpan = $this->agent->startSpan('Mongo/Query/' . $event->getCommandName()); 49 | 50 | if ($activeSpan === null) { 51 | return; 52 | } 53 | 54 | $activeSpan->tag('db', $event->getDatabaseName()); 55 | $activeSpan->tag('operationId', $event->getOperationId()); 56 | $activeSpan->tag('requestId', $event->getRequestId()); 57 | } 58 | 59 | public function commandSucceeded(CommandSucceededEvent $event): void 60 | { 61 | $this->agent->stopSpan(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Unit/Laravel/Facades/ScoutApmTest.php: -------------------------------------------------------------------------------- 1 | agent = $this->createMock(ScoutApmAgent::class); 23 | 24 | ScoutApmFacade::clearResolvedInstances(); 25 | ScoutApmFacade::swap($this->agent); 26 | } 27 | 28 | /** @return string[][]|string[][][]|null[][][]|callable[][][] */ 29 | public function proxiedMethodsProvider(): array 30 | { 31 | $callable = static function (): void { 32 | }; 33 | 34 | return [ 35 | ['connect', []], 36 | ['enabled', []], 37 | ['startSpan', ['operation', null]], 38 | ['stopSpan', []], 39 | ['instrument', ['type', 'name', $callable]], 40 | ['webTransaction', ['name', $callable]], 41 | ['backgroundTransaction', ['name', $callable]], 42 | ['addContext', ['tag', 'value']], 43 | ['tagRequest', ['tag', 'value']], 44 | ['ignored', ['tag']], 45 | ['ignore', []], 46 | ['send', []], 47 | ]; 48 | } 49 | 50 | /** 51 | * @param mixed[] $args 52 | * 53 | * @dataProvider proxiedMethodsProvider 54 | */ 55 | public function testFacadeProxiesMethodsToRealAgent(string $method, array $args): void 56 | { 57 | $this->agent->expects(self::once()) 58 | ->method($method) 59 | ->with(...$args); 60 | 61 | ScoutApmFacade::$method(...$args); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 | ./tests/Unit 20 | ./tests/Unit/Laravel 21 | ./tests/Unit/ScoutApmBundle 22 | 23 | 24 | ./tests/Integration 25 | 26 | 27 | ./tests/Unit/Laravel 28 | 29 | 30 | ./tests/Unit/ScoutApmBundle 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ./src/ 40 | 41 | ./src/ScoutApmBundle/Twig/TwigMethods-Twig2.php 42 | ./src/ScoutApmBundle/Twig/TwigMethods-Twig3.php 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/Helper/FormatUrlPathAndQuery.php: -------------------------------------------------------------------------------- 1 | $filteredParameters 23 | * 24 | * @psalm-pure 25 | */ 26 | public static function forUriReportingConfiguration(string $uriReportingConfiguration, array $filteredParameters, string $subjectUrlPath): string 27 | { 28 | if ($uriReportingConfiguration === ConfigKey::URI_REPORTING_FULL_PATH) { 29 | return $subjectUrlPath; 30 | } 31 | 32 | $urlParts = parse_url($subjectUrlPath); 33 | $path = array_key_exists('path', $urlParts) && is_string($urlParts['path']) ? $urlParts['path'] : '/'; 34 | $fragment = array_key_exists('fragment', $urlParts) && is_string($urlParts['fragment']) ? '#' . $urlParts['fragment'] : ''; 35 | 36 | if ($uriReportingConfiguration === ConfigKey::URI_REPORTING_PATH_ONLY) { 37 | return $path . $fragment; 38 | } 39 | 40 | $queryString = array_key_exists('query', $urlParts) && is_string($urlParts['query']) ? $urlParts['query'] : ''; 41 | 42 | /** @psalm-suppress ImpureFunctionCall - when called with second param, this should be a pure function call */ 43 | parse_str($queryString, $queryParts); 44 | 45 | $filteredQuery = FilterParameters::forUriReportingConfiguration($filteredParameters, $queryParts); 46 | 47 | if (! count($filteredQuery)) { 48 | return $path . $fragment; 49 | } 50 | 51 | return $path . '?' . urldecode(http_build_query($filteredQuery)) . $fragment; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/roave-backwards-compatibility-check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -uo pipefail 4 | 5 | cd "$(dirname "$0")/../.." || exit 2 6 | 7 | # This file is a hack to suppress warnings from Roave BC check 8 | # Based on: https://github.com/guzzle/guzzle/blob/7a30f3bc91b3ab57860efbe8272649cc23dbbcc2/.github/workflows/bc.entrypoint 9 | 10 | echo "Running BC check, please wait..." 11 | 12 | # Capture output to variable AND print it 13 | OUTPUT=$(vendor/bin/roave-backward-compatibility-check --format=markdown "$@" 2>&1) 14 | 15 | # Remove rows we want to suppress 16 | #OUTPUT=`echo "$OUTPUT" | sed '/Roave\\\BetterReflection\\\Reflection\\\ReflectionClass "Symfony\\\Component\\\HttpKernel\\\Event\\\FilterControllerEvent" could not be found in the located source/'d` 17 | #OUTPUT=`echo "$OUTPUT" | sed '/Roave\\\BetterReflection\\\Reflection\\\ReflectionClass "Scoutapm\\\ScoutApmBundle\\\Twig\\\TwigMethods" could not be found in the located source/'d` 18 | #OUTPUT=`echo "$OUTPUT" | sed '/Value of constant Twig\\\Environment::.* changed from .* to .*/'d` 19 | OUTPUT=`echo "$OUTPUT" | sed '/Method Illuminate\\\Support\\\Facades\\\Facade::.*() was removed/'d` 20 | 21 | # Number of rows we found with "[BC]" in them 22 | BC_BREAKS=`echo "$OUTPUT" | grep -o '\[BC\]' | wc -l | awk '{ print $1 }'` 23 | 24 | # The last row of the output is "X backwards-incompatible changes detected". Find X. 25 | STATED_BREAKS=`echo "$OUTPUT" | tail -n 1 | awk -F' ' '{ print $1 }'` 26 | 27 | EXPECTED_STATED_BREAKS=2 28 | 29 | echo "$OUTPUT" 30 | echo "Lines with [BC] not filtered: $BC_BREAKS" 31 | echo "Stated breaks: $STATED_BREAKS out of expected $EXPECTED_STATED_BREAKS" 32 | 33 | # If 34 | # We found "[BC]" in the command output after we removed suppressed lines 35 | # OR 36 | # We have suppressed X number of BC breaks. If $STATED_BREAKS is larger than X 37 | # THEN 38 | # exit 1 39 | if [ $BC_BREAKS -gt 0 ] || [ $STATED_BREAKS -gt $EXPECTED_STATED_BREAKS ]; then 40 | echo "EXIT 1" 41 | exit 1 42 | fi 43 | 44 | # No BC breaks found 45 | echo "EXIT 0" 46 | exit 0 47 | -------------------------------------------------------------------------------- /src/Helper/RootPackageGitSha/FindRootPackageGitShaWithHerokuAndConfigOverride.php: -------------------------------------------------------------------------------- 1 | config = $config; 29 | $this->superglobals = $superglobals; 30 | } 31 | 32 | public function __invoke(): string 33 | { 34 | /** @var mixed $revisionShaConfiguration */ 35 | $revisionShaConfiguration = $this->config->get(ConfigKey::REVISION_SHA); 36 | if (is_string($revisionShaConfiguration) && $revisionShaConfiguration !== '') { 37 | return $revisionShaConfiguration; 38 | } 39 | 40 | $env = $this->superglobals->env(); 41 | $herokuSlugCommit = $env['HEROKU_SLUG_COMMIT'] ?? null; 42 | if (is_string($herokuSlugCommit) && $herokuSlugCommit !== '') { 43 | return $herokuSlugCommit; 44 | } 45 | 46 | if (class_exists(InstalledVersions::class) && method_exists(InstalledVersions::class, 'getRootPackage')) { 47 | /** @var mixed $rootPackage */ 48 | $rootPackage = InstalledVersions::getRootPackage(); 49 | if (is_array($rootPackage) && array_key_exists('reference', $rootPackage) && is_string($rootPackage['reference'])) { 50 | return $rootPackage['reference']; 51 | } 52 | } 53 | 54 | return ''; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Cache/DevNullCacheSimpleCache2And3.php: -------------------------------------------------------------------------------- 1 | get($key, $default); 56 | }, 57 | $keysAsArray 58 | ) 59 | ); 60 | } 61 | 62 | /** @inheritDoc */ 63 | public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool 64 | { 65 | return true; 66 | } 67 | 68 | /** @inheritDoc */ 69 | public function deleteMultiple(iterable $keys): bool 70 | { 71 | return true; 72 | } 73 | 74 | /** @inheritDoc */ 75 | public function has(string $key): bool 76 | { 77 | return false; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/Unit/Config/Source/DerivedSourceTest.php: -------------------------------------------------------------------------------- 1 | config = new Config(); 26 | 27 | $this->derivedSource = new DerivedSource($this->config); 28 | } 29 | 30 | public function testHasKey(): void 31 | { 32 | self::assertTrue($this->derivedSource->hasKey(ConfigKey::CORE_AGENT_SOCKET_PATH)); 33 | self::assertTrue($this->derivedSource->hasKey(ConfigKey::CORE_AGENT_FULL_NAME)); 34 | self::assertTrue($this->derivedSource->hasKey(ConfigKey::CORE_AGENT_TRIPLE)); 35 | self::assertFalse($this->derivedSource->hasKey('is_array')); 36 | } 37 | 38 | public function testGetReturnsNullWhenConfigKeyDoesNotExist(): void 39 | { 40 | self::assertNull($this->derivedSource->get('not an actual key')); 41 | } 42 | 43 | public function testCoreAgentFullNameIsDerivedCorrectly(): void 44 | { 45 | self::assertStringMatchesFormat( 46 | 'scout_apm_core-v%d.%d.%d-%s-linux-musl', 47 | $this->derivedSource->get(ConfigKey::CORE_AGENT_FULL_NAME) 48 | ); 49 | } 50 | 51 | public function testSocketPathIsDerivedCorrectly(): void 52 | { 53 | self::assertSame( 54 | 'tcp://127.0.0.1:6590', 55 | $this->derivedSource->get(ConfigKey::CORE_AGENT_SOCKET_PATH) 56 | ); 57 | } 58 | 59 | public function testMuslIsUsedForLibcVersion(): void 60 | { 61 | self::assertStringEndsWith('linux-musl', $this->derivedSource->get(ConfigKey::CORE_AGENT_TRIPLE)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/Twig/TwigDecorator.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 25 | $this->agent = $agent; 26 | } 27 | 28 | /** @param string|TemplateWrapper $nameOrTemplateWrapper */ 29 | private function nameOrConvertTemplateWrapperToString($nameOrTemplateWrapper): string 30 | { 31 | if (! $nameOrTemplateWrapper instanceof TemplateWrapper) { 32 | return $nameOrTemplateWrapper; 33 | } 34 | 35 | return $nameOrTemplateWrapper->getTemplateName(); 36 | } 37 | 38 | /** @return mixed */ 39 | private function instrument(string $name, Closure $callable) 40 | { 41 | return $this->agent->instrument( 42 | 'View', 43 | $name, 44 | $callable 45 | ); 46 | } 47 | 48 | /** {@inheritDoc} */ 49 | public function render($name, array $context = []): string 50 | { 51 | return $this->instrument( 52 | $this->nameOrConvertTemplateWrapperToString($name), 53 | function () use ($name, $context) { 54 | return $this->twig->render($name, $context); 55 | } 56 | ); 57 | } 58 | 59 | /** {@inheritDoc} */ 60 | public function display($name, array $context = []): void 61 | { 62 | $this->instrument( 63 | $this->nameOrConvertTemplateWrapperToString($name), 64 | function () use ($name, $context): void { 65 | $this->twig->display($name, $context); 66 | } 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | crossCompatibleRootNode($treeBuilder) 26 | ->children() 27 | ->arrayNode('scoutapm') 28 | ->children(); 29 | 30 | foreach (ConfigKey::allConfigurationKeys() as $configKey) { 31 | $children = $children->scalarNode($configKey)->defaultNull()->end(); 32 | } 33 | 34 | $children->end() 35 | ->end() 36 | ->end(); 37 | 38 | return $treeBuilder; 39 | } 40 | 41 | /** @return NodeDefinition|ArrayNodeDefinition */ 42 | private function crossCompatibleRootNode(TreeBuilder $treeBuilder): NodeDefinition 43 | { 44 | /** @noinspection ClassMemberExistenceCheckInspection */ 45 | if (method_exists($treeBuilder, 'getRootNode')) { 46 | return $treeBuilder->getRootNode(); 47 | } 48 | 49 | /** 50 | * @psalm-suppress DeprecatedMethod newer SF versions have the getRootNode method, so won't reach here 51 | * @psalm-suppress UndefinedMethod even newer SF versions remove this method entirely, but shouldn't reach here 52 | */ 53 | return $treeBuilder->root(self::ROOT_NODE_NAME); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ScoutApmBundle/ScoutApmBundle.php: -------------------------------------------------------------------------------- 1 | safelyCheckForSymfonyPackagePresence(); 24 | 25 | /** @noinspection UnusedFunctionResultInspection */ 26 | array_map( 27 | function (string $connectionServiceName): void { 28 | if ($this->container === null) { 29 | return; 30 | } 31 | 32 | if (! $this->container->has($connectionServiceName)) { 33 | return; 34 | } 35 | 36 | $sqlLogger = $this->container->get(DoctrineSqlLogger::class); 37 | assert($sqlLogger instanceof DoctrineSqlLogger); 38 | $connection = $this->container->get($connectionServiceName); 39 | assert($connection instanceof Connection); 40 | 41 | $sqlLogger->registerWith($connection); 42 | }, 43 | self::DOCTRINE_CONNECTIONS 44 | ); 45 | } 46 | 47 | private function safelyCheckForSymfonyPackagePresence(): void 48 | { 49 | if ($this->container === null) { 50 | return; 51 | } 52 | 53 | if (! $this->container->has(LoggerInterface::class)) { 54 | return; 55 | } 56 | 57 | $logger = $this->container->get(LoggerInterface::class); 58 | if (! $logger instanceof LoggerInterface) { 59 | return; 60 | } 61 | 62 | ComposerPackagesCheck::logIfSymfonyPackageNotPresent($logger); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Events/Tag/TagSpan.php: -------------------------------------------------------------------------------- 1 | spanId = $spanId; 32 | } 33 | 34 | /** 35 | * @return string[][][]|array[][][]|bool[][][]|null[][][] 36 | * @psalm-return list< 37 | * array{ 38 | * TagSpan: array{ 39 | * request_id: string, 40 | * span_id: string, 41 | * tag: string, 42 | * value: mixed, 43 | * timestamp: string 44 | * } 45 | * } 46 | * > 47 | */ 48 | public function jsonSerialize(): array 49 | { 50 | // Format the timestamp 51 | $timestamp = DateTime::createFromFormat('U.u', sprintf('%.6F', $this->timestamp)); 52 | $timestamp->setTimeZone(new DateTimeZone('UTC')); 53 | $timestamp = $timestamp->format('Y-m-d\TH:i:s.u\Z'); 54 | 55 | return [ 56 | [ 57 | 'TagSpan' => [ 58 | 'request_id' => $this->requestId->toString(), 59 | 'span_id' => $this->spanId->toString(), 60 | 'tag' => $this->tag, 61 | 'value' => $this->value, 62 | 'timestamp' => $timestamp, 63 | ], 64 | ], 65 | ]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Extension/Version.php: -------------------------------------------------------------------------------- 1 | 0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$#'; 26 | 27 | /** @var int */ 28 | private $major; 29 | /** @var int */ 30 | private $minor; 31 | /** @var int */ 32 | private $patch; 33 | 34 | private function __construct(int $major, int $minor, int $patch) 35 | { 36 | Assert::greaterThanEq($major, 0); 37 | Assert::greaterThanEq($minor, 0); 38 | Assert::greaterThanEq($patch, 0); 39 | 40 | $this->major = $major; 41 | $this->minor = $minor; 42 | $this->patch = $patch; 43 | } 44 | 45 | public static function fromString(string $versionString): self 46 | { 47 | if (! preg_match(self::REGEX, $versionString, $parts)) { 48 | throw new RuntimeException(sprintf('Unable to parse version %s', $versionString)); 49 | } 50 | 51 | return new self((int) $parts['major'], (int) $parts['minor'], (int) $parts['patch']); 52 | } 53 | 54 | public function isOlderThan(self $otherVersion): bool 55 | { 56 | return (($this->major * 1000000) + ($this->minor * 1000) + $this->patch) 57 | < (($otherVersion->major * 1000000) + ($otherVersion->minor * 1000) + $otherVersion->patch); 58 | } 59 | 60 | public function toString(): string 61 | { 62 | return sprintf('%d.%d.%d', $this->major, $this->minor, $this->patch); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Laravel/Middleware/ActionInstrument.php: -------------------------------------------------------------------------------- 1 | agent = $agent; 30 | $this->logger = $logger; 31 | $this->determineControllerName = $determineControllerName; 32 | } 33 | 34 | /** 35 | * @psalm-param Closure(Request):mixed $next 36 | * 37 | * @return mixed 38 | * 39 | * @throws Throwable 40 | */ 41 | public function handle(Request $request, Closure $next) 42 | { 43 | $this->logger->debug('Handle ActionInstrument'); 44 | 45 | return $this->agent->webTransaction( 46 | 'unknown', 47 | /** @return mixed */ 48 | function (?SpanReference $span) use ($request, $next) { 49 | try { 50 | /** @var mixed $response */ 51 | $response = $next($request); 52 | } catch (Throwable $e) { 53 | $this->agent->tagRequest('error', 'true'); 54 | 55 | throw $e; 56 | } 57 | 58 | if ($span !== null) { 59 | $determineControllerName = $this->determineControllerName; 60 | $span->updateName($determineControllerName($request)); 61 | } 62 | 63 | return $response; 64 | } 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/Unit/ScoutApmBundle/ScoutApmAgentFactoryTest.php: -------------------------------------------------------------------------------- 1 | createMock(LoggerInterface::class); 25 | $cache = $this->createMock(CacheInterface::class); 26 | $connector = $this->createMock(Connector::class); 27 | $phpExtension = $this->createMock(ExtensionCapabilities::class); 28 | 29 | $connector->expects(self::at(3)) 30 | ->method('sendCommand') 31 | ->with(self::callback(static function (Metadata $metadata) { 32 | $flattenedMetadata = json_decode(json_encode($metadata), true)['ApplicationEvent']['event_value']; 33 | 34 | self::assertArrayHasKey('framework', $flattenedMetadata); 35 | self::assertSame('Symfony', $flattenedMetadata['framework']); 36 | 37 | self::assertArrayHasKey('framework_version', $flattenedMetadata); 38 | self::assertNotSame('', $flattenedMetadata['framework_version']); 39 | 40 | return true; 41 | })); 42 | 43 | $agent = ScoutApmAgentFactory::createAgent( 44 | $logger, 45 | $cache, 46 | $connector, 47 | $phpExtension, 48 | [ 49 | ConfigKey::APPLICATION_NAME => 'Symfony Agent Factory Test', 50 | ConfigKey::APPLICATION_KEY => 'test application key', 51 | ConfigKey::MONITORING_ENABLED => true, 52 | ] 53 | ); 54 | 55 | $agent->send(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Helper/ComposerPackagesCheck.php: -------------------------------------------------------------------------------- 1 | info(sprintf( 57 | 'We detected you are running %s, but did not have %s installed.', 58 | $frameworkDetected, 59 | $requiredPackage 60 | )); 61 | } 62 | 63 | private static function packageIsInstalled(string $package): bool 64 | { 65 | // Can't detect anything without Composer v2 API :( 66 | if (! class_exists(InstalledVersions::class)) { 67 | return true; 68 | } 69 | 70 | return InstalledVersions::isInstalled($package); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Unit/Extension/VersionTest.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function olderThanVersionProvider(): array 18 | { 19 | return [ 20 | ['testVersion' => '1.0.0', 'isOlderThan' => '2.0.0', 'expectedResult' => true], 21 | ['testVersion' => '1.1.0', 'isOlderThan' => '1.2.0', 'expectedResult' => true], 22 | ['testVersion' => '1.1.1', 'isOlderThan' => '1.1.2', 'expectedResult' => true], 23 | ['testVersion' => '2.0.0', 'isOlderThan' => '1.0.0', 'expectedResult' => false], 24 | ['testVersion' => '1.2.0', 'isOlderThan' => '1.1.0', 'expectedResult' => false], 25 | ['testVersion' => '1.1.2', 'isOlderThan' => '1.1.1', 'expectedResult' => false], 26 | ['testVersion' => '1.0.0', 'isOlderThan' => '1.0.0', 'expectedResult' => false], 27 | ['testVersion' => '1.1.0', 'isOlderThan' => '1.1.0', 'expectedResult' => false], 28 | ['testVersion' => '1.1.1', 'isOlderThan' => '1.1.1', 'expectedResult' => false], 29 | ['testVersion' => '1.3.0', 'isOlderThan' => '0.0.1', 'expectedResult' => false], 30 | ['testVersion' => '2.0.0', 'isOlderThan' => '1.0.2', 'expectedResult' => false], 31 | ['testVersion' => '2.0.0', 'isOlderThan' => '1.2.0', 'expectedResult' => false], 32 | ]; 33 | } 34 | 35 | /** @dataProvider olderThanVersionProvider */ 36 | public function testOlderThan(string $testVersion, string $olderThan, bool $expectedResult): void 37 | { 38 | self::assertSame( 39 | $expectedResult, 40 | Version::fromString($testVersion) 41 | ->isOlderThan(Version::fromString($olderThan)) 42 | ); 43 | } 44 | 45 | public function testConvertsToString(): void 46 | { 47 | self::assertSame('1.2.3', Version::fromString('1.2.3')->toString()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Extension/PotentiallyAvailableExtensionCapabilities.php: -------------------------------------------------------------------------------- 1 | extensionIsAvailable()) { 21 | return; 22 | } 23 | 24 | // If the function doesn't exist, we're probably using an older `scoutapm` extension which doesn't need enabling 25 | if (! function_exists('scoutapm_enable_instrumentation')) { 26 | return; 27 | } 28 | 29 | scoutapm_enable_instrumentation(true); 30 | } 31 | 32 | /** 33 | * @return RecordedCall[] 34 | * @psalm-return list 35 | */ 36 | public function getCalls(): array 37 | { 38 | if (! $this->extensionIsAvailable()) { 39 | return []; 40 | } 41 | 42 | /** @psalm-suppress UndefinedFunction */ 43 | return array_map( 44 | static function (array $call): RecordedCall { 45 | return RecordedCall::fromExtensionLoggedCallArray($call); 46 | }, 47 | scoutapm_get_calls() 48 | ); 49 | } 50 | 51 | public function clearRecordedCalls(): void 52 | { 53 | if (! $this->extensionIsAvailable()) { 54 | return; 55 | } 56 | 57 | /** @psalm-suppress UndefinedFunction */ 58 | scoutapm_get_calls(); 59 | } 60 | 61 | private function extensionIsAvailable(): bool 62 | { 63 | return extension_loaded('scoutapm') 64 | && function_exists('scoutapm_get_calls'); 65 | } 66 | 67 | public function version(): ?Version 68 | { 69 | if (! $this->extensionIsAvailable()) { 70 | return null; 71 | } 72 | 73 | try { 74 | return Version::fromString(phpversion('scoutapm')); 75 | } catch (Throwable $anything) { 76 | return null; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Helper/LocateFileOrFolder/LocateFileOrFolder.php: -------------------------------------------------------------------------------- 1 | __invoke('composer.json') with default settings, we try, in order: 20 | * - /home/user/workspace/my-app/vendor/scoutapp/scout-apm-php/src/Helper/composer.json (Fail, but is skipped by default) 21 | * - /home/user/workspace/my-app/vendor/scoutapp/scout-apm-php/src/composer.json (Fail, but is skipped by default) 22 | * - /home/user/workspace/my-app/vendor/scoutapp/scout-apm-php/composer.json (Fail, but is skipped by default) 23 | * - /home/user/workspace/my-app/vendor/scoutapp/composer.json (Fail, doesn't exist) 24 | * - /home/user/workspace/my-app/vendor/composer.json (Fail, doesn't exist) 25 | * - /home/user/workspace/my-app/composer.json (Success - will return `/home/user/workspace/my-app`) 26 | * 27 | * Note: when developing on the library, this will usually return `null`, since the paths (in order) are: 28 | * - /home/user/workspace/scout-apm-php/src/Helper/composer.json (Fail, skipped by default) 29 | * - /home/user/workspace/scout-apm-php/src/composer.json (Fail, skipped by default) 30 | * - /home/user/workspace/scout-apm-php/composer.json (Success, but skipped by default) 31 | * - /home/user/workspace/composer.json (Fail, doesn't exist) 32 | * - /home/user/composer.json (Fail, doesn't exist) 33 | * - /home/composer.json (Fail, doesn't exist) 34 | * - /composer.json (Fail, doesn't exist, reached "root", so return `null`) 35 | * 36 | * @internal This is not covered by BC promise 37 | */ 38 | public function __invoke(string $fileOrFolder, int $skipLevels = self::SKIP_LEVELS_DEFAULT): ?string; 39 | } 40 | -------------------------------------------------------------------------------- /tests/Unit/Laravel/Database/QueryListenerTest.php: -------------------------------------------------------------------------------- 1 | agent = $this->createMock(ScoutApmAgent::class); 31 | 32 | $this->queryListener = new QueryListener($this->agent); 33 | } 34 | 35 | public function testSqlQueryIsLogged(): void 36 | { 37 | $query = new QueryExecuted('SELECT 1', [], 1000, $this->createMock(Connection::class)); 38 | 39 | $spanMock = $this->createMock(Span::class); 40 | $spanMock->expects(self::once()) 41 | ->method('tag') 42 | ->with('db.statement', 'SELECT 1'); 43 | 44 | $this->agent->expects(self::once()) 45 | ->method('startSpan') 46 | ->with('SQL/Query', self::isType(IsType::TYPE_FLOAT)) 47 | ->willReturn(SpanReference::fromSpan($spanMock)); 48 | 49 | $this->agent->expects(self::once()) 50 | ->method('stopSpan'); 51 | 52 | $this->queryListener->__invoke($query); 53 | } 54 | 55 | public function testSqlQueryIsNotLoggedWhenStartSpanReturnsNull(): void 56 | { 57 | $query = new QueryExecuted('SELECT 1', [], 1000, $this->createMock(Connection::class)); 58 | 59 | $this->agent->expects(self::once()) 60 | ->method('startSpan') 61 | ->with('SQL/Query', self::isType(IsType::TYPE_FLOAT)) 62 | ->willReturn(null); 63 | 64 | $this->agent->expects(self::never()) 65 | ->method('stopSpan'); 66 | 67 | $this->queryListener->__invoke($query); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Unit/Laravel/Queue/JobQueueListenerTest.php: -------------------------------------------------------------------------------- 1 | agent = $this->createMock(ScoutApmAgent::class); 28 | 29 | $this->jobQueueListener = new JobQueueListener($this->agent); 30 | } 31 | 32 | public function testRequestIsReset(): void 33 | { 34 | $this->agent->expects(self::once()) 35 | ->method('startNewRequest'); 36 | 37 | $this->jobQueueListener->startNewRequestForJob(); 38 | } 39 | 40 | /** @throws Exception */ 41 | public function testSpanIsStarted(): void 42 | { 43 | $this->agent->expects(self::once()) 44 | ->method('startSpan') 45 | ->with('Job/Foo'); 46 | 47 | $job = $this->createMock(Job::class); 48 | $job->expects(self::once()) 49 | ->method('resolveName') 50 | ->willReturn('Foo'); 51 | 52 | $event = new JobProcessing('connection', $job); 53 | 54 | $this->jobQueueListener->startSpanForJob($event); 55 | } 56 | 57 | /** @throws Exception */ 58 | public function testSpanIsStopped(): void 59 | { 60 | $this->agent->expects(self::once()) 61 | ->method('stopSpan'); 62 | 63 | $this->jobQueueListener->stopSpanForJob(); 64 | } 65 | 66 | /** @throws Exception */ 67 | public function testAgentConnectsAndSendsWhenRequestIsToBeSent(): void 68 | { 69 | $this->agent->expects(self::once()) 70 | ->method('connect'); 71 | 72 | $this->agent->expects(self::once()) 73 | ->method('send'); 74 | 75 | $this->jobQueueListener->sendRequestForJob(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Helper/FindApplicationRoot/FindApplicationRootWithConfigOverride.php: -------------------------------------------------------------------------------- 1 | locateFileOrFolder = $locateFileOrFolder; 30 | $this->config = $config; 31 | $this->superglobals = $superglobals; 32 | } 33 | 34 | public function __invoke(): string 35 | { 36 | if (is_string($this->memoizedApplicationRoot)) { 37 | return $this->memoizedApplicationRoot; 38 | } 39 | 40 | /** @var mixed $applicationRootConfiguration */ 41 | $applicationRootConfiguration = $this->config->get(ConfigKey::APPLICATION_ROOT); 42 | if (is_string($applicationRootConfiguration) && $applicationRootConfiguration !== '') { 43 | $this->memoizedApplicationRoot = $applicationRootConfiguration; 44 | 45 | return $applicationRootConfiguration; 46 | } 47 | 48 | $composerJsonLocation = $this->locateFileOrFolder->__invoke('composer.json'); 49 | if ($composerJsonLocation !== null) { 50 | $this->memoizedApplicationRoot = $composerJsonLocation; 51 | 52 | return $composerJsonLocation; 53 | } 54 | 55 | $server = $this->superglobals->server(); 56 | if (! array_key_exists('DOCUMENT_ROOT', $server)) { 57 | $this->memoizedApplicationRoot = ''; 58 | 59 | return ''; 60 | } 61 | 62 | $this->memoizedApplicationRoot = $server['DOCUMENT_ROOT']; 63 | 64 | return $server['DOCUMENT_ROOT']; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/CoreAgent/AutomaticDownloadAndLaunchManager.php: -------------------------------------------------------------------------------- 1 | config = $config; 35 | $this->logger = $logger; 36 | 37 | $this->downloader = $downloader; 38 | $this->launcher = $launcher; 39 | $this->verifier = $verifier; 40 | } 41 | 42 | public function launch(): bool 43 | { 44 | if (! $this->config->get(ConfigKey::CORE_AGENT_LAUNCH_ENABLED)) { 45 | $this->logger->debug(sprintf( 46 | "Not attempting to launch Core Agent due to '%s' setting.", 47 | ConfigKey::CORE_AGENT_LAUNCH_ENABLED 48 | )); 49 | 50 | return false; 51 | } 52 | 53 | $coreAgentBinPath = $this->verifier->verify(); 54 | if ($coreAgentBinPath === null) { 55 | if (! $this->config->get(ConfigKey::CORE_AGENT_DOWNLOAD_ENABLED)) { 56 | $this->logger->debug(sprintf( 57 | "Not attempting to download Core Agent due to '%s' setting.", 58 | ConfigKey::CORE_AGENT_DOWNLOAD_ENABLED 59 | )); 60 | 61 | return false; 62 | } 63 | 64 | $this->downloader->download(); 65 | } 66 | 67 | $coreAgentBinPath = $this->verifier->verify(); 68 | if ($coreAgentBinPath === null) { 69 | $this->logger->debug( 70 | 'Failed to verify Core Agent. Not launching Core Agent.' 71 | ); 72 | 73 | return false; 74 | } 75 | 76 | return $this->launcher->launch($coreAgentBinPath); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Helper/FindRequestHeaders/FindRequestHeadersUsingServerGlobal.php: -------------------------------------------------------------------------------- 1 | superglobals = $superglobals; 31 | } 32 | 33 | /** 34 | * @internal 35 | * 36 | * @return array 37 | */ 38 | public function __invoke(): array 39 | { 40 | $qualifyingServerKeys = $this->onlyQualifyingServerItems($this->superglobals->server()); 41 | 42 | return array_combine( 43 | array_map( 44 | static function (string $key): string { 45 | if (in_array(strtolower(substr($key, 0, 5)), ['http_', 'http-'], true)) { 46 | $key = substr($key, 5); 47 | } 48 | 49 | return ucwords(str_replace('_', '-', strtolower($key)), '-'); 50 | }, 51 | array_keys($qualifyingServerKeys) 52 | ), 53 | $qualifyingServerKeys 54 | ); 55 | } 56 | 57 | /** 58 | * @param array $server 59 | * 60 | * @return array 61 | * 62 | * @psalm-suppress InvalidReturnType 63 | */ 64 | private function onlyQualifyingServerItems(array $server): array 65 | { 66 | /** @psalm-suppress InvalidReturnStatement */ 67 | return array_filter( 68 | $server, 69 | /** 70 | * @param mixed $value 71 | * @param mixed $key 72 | */ 73 | static function ($value, $key): bool { 74 | return is_string($key) 75 | && $value !== ''; 76 | }, 77 | ARRAY_FILTER_USE_BOTH 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Config/Helper/RequireValidFilteredParameters.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private static function fromConfigForGivenKey(Config $config, string $filteredParametersConfigKey): array 28 | { 29 | /** @var mixed $uriFilteredParameters */ 30 | $uriFilteredParameters = $config->get($filteredParametersConfigKey); 31 | 32 | /** @var list $defaultFilteredParameters */ 33 | $defaultFilteredParameters = (new Config\Source\DefaultSource())->get($filteredParametersConfigKey); 34 | 35 | if (! is_array($uriFilteredParameters)) { 36 | return $defaultFilteredParameters; 37 | } 38 | 39 | foreach ($uriFilteredParameters as $filteredParameter) { 40 | if (! is_string($filteredParameter)) { 41 | throw new InvalidArgumentException(sprintf( 42 | 'Parameter value for configuration "%s" was invalid - expected string, found %s', 43 | $filteredParametersConfigKey, 44 | is_object($filteredParameter) ? get_class($filteredParameter) : gettype($filteredParameter) 45 | )); 46 | } 47 | } 48 | 49 | /** @psalm-var array $uriFilteredParameters */ 50 | return array_values($uriFilteredParameters); 51 | } 52 | 53 | /** @psalm-return list */ 54 | public static function fromConfigForErrors(Config $config): array 55 | { 56 | return self::fromConfigForGivenKey($config, ConfigKey::ERRORS_FILTERED_PARAMETERS); 57 | } 58 | 59 | /** @psalm-return list */ 60 | public static function fromConfigForUris(Config $config): array 61 | { 62 | return self::fromConfigForGivenKey($config, ConfigKey::URI_FILTERED_PARAMETERS); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Unit/Laravel/Middleware/MiddlewareInstrumentTest.php: -------------------------------------------------------------------------------- 1 | agent = $this->createMock(ScoutApmAgent::class); 35 | $this->logger = $this->createMock(LoggerInterface::class); 36 | 37 | $this->middleware = new MiddlewareInstrument( 38 | $this->agent, 39 | new FilteredLogLevelDecorator($this->logger, LogLevel::DEBUG) 40 | ); 41 | } 42 | 43 | public function testHandleWrappsMiddlewareExecutionInInstrumentation(): void 44 | { 45 | $expectedResponse = new Response(); 46 | 47 | $this->agent 48 | ->expects(self::once()) 49 | ->method('instrument') 50 | ->with('Middleware', 'all', self::isType(IsType::TYPE_CALLABLE)) 51 | ->willReturnCallback( 52 | /** @return mixed */ 53 | static function (string $type, string $name, callable $transaction) { 54 | return $transaction(); 55 | } 56 | ); 57 | 58 | $this->logger->expects(self::once()) 59 | ->method('log') 60 | ->with(LogLevel::DEBUG, '[Scout] Handle MiddlewareInstrument'); 61 | 62 | self::assertSame( 63 | $expectedResponse, 64 | $this->middleware->handle( 65 | new Request(), 66 | static function () use ($expectedResponse) { 67 | return $expectedResponse; 68 | } 69 | ) 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Unit/Laravel/Middleware/IgnoredEndpointsTest.php: -------------------------------------------------------------------------------- 1 | agent = $this->createMock(ScoutApmAgent::class); 28 | 29 | $this->middleware = new IgnoredEndpoints($this->agent); 30 | } 31 | 32 | public function testHandleIgnoresPathIfItIsIgnored(): void 33 | { 34 | $request = new Request(); 35 | $expectedResponse = new Response(); 36 | 37 | $this->agent->expects(self::once()) 38 | ->method('ignored') 39 | ->with('/' . $request->path()) 40 | ->willReturn(true); 41 | 42 | $this->agent->expects(self::once()) 43 | ->method('ignore'); 44 | 45 | self::assertSame( 46 | $expectedResponse, 47 | $this->middleware->handle( 48 | new Request(), 49 | static function () use ($expectedResponse) { 50 | return $expectedResponse; 51 | } 52 | ) 53 | ); 54 | } 55 | 56 | public function testHandleDoesNothingIfPathIsNotIgnored(): void 57 | { 58 | $request = new Request(); 59 | $expectedResponse = new Response(); 60 | 61 | $this->agent->expects(self::once()) 62 | ->method('ignored') 63 | ->with('/' . $request->path()) 64 | ->willReturn(false); 65 | 66 | $this->agent->expects(self::never()) 67 | ->method('ignore'); 68 | 69 | self::assertSame( 70 | $expectedResponse, 71 | $this->middleware->handle( 72 | new Request(), 73 | static function () use ($expectedResponse) { 74 | return $expectedResponse; 75 | } 76 | ) 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /psalm.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /.github/workflows/release-on-milestone-closed-triggering-release-event.yml: -------------------------------------------------------------------------------- 1 | # Alternate workflow example. 2 | # This one is identical to the one in release-on-milestone.yml, with one change: 3 | # the Release step uses the ORGANIZATION_ADMIN_TOKEN instead, to allow it to 4 | # trigger a release workflow event. This is useful if you have other actions 5 | # that intercept that event. 6 | 7 | name: "Automatic Releases" 8 | 9 | on: 10 | milestone: 11 | types: 12 | - "closed" 13 | 14 | jobs: 15 | release: 16 | name: "GIT tag, release & create merge-up PR" 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: "Checkout" 21 | uses: actions/checkout@v4 22 | 23 | - name: "Release" 24 | uses: "laminas/automatic-releases@v1" 25 | with: 26 | command-name: "laminas:automatic-releases:release" 27 | env: 28 | "GITHUB_TOKEN": ${{ secrets.ORGANIZATION_ADMIN_TOKEN }} 29 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 30 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 31 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 32 | 33 | - name: "Create Merge-Up Pull Request" 34 | uses: "laminas/automatic-releases@v1" 35 | with: 36 | command-name: "laminas:automatic-releases:create-merge-up-pull-request" 37 | env: 38 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 39 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 40 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 41 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 42 | 43 | - name: "Create and/or Switch to new Release Branch" 44 | uses: "laminas/automatic-releases@v1" 45 | with: 46 | command-name: "laminas:automatic-releases:switch-default-branch-to-next-minor" 47 | env: 48 | "GITHUB_TOKEN": ${{ secrets.ORGANIZATION_ADMIN_TOKEN }} 49 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 50 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 51 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 52 | 53 | - name: "Create new milestones" 54 | uses: "laminas/automatic-releases@v1" 55 | with: 56 | command-name: "laminas:automatic-releases:create-milestones" 57 | env: 58 | "GITHUB_TOKEN": ${{ secrets.GITHUB_TOKEN }} 59 | "SIGNING_SECRET_KEY": ${{ secrets.SIGNING_SECRET_KEY }} 60 | "GIT_AUTHOR_NAME": ${{ secrets.GIT_AUTHOR_NAME }} 61 | "GIT_AUTHOR_EMAIL": ${{ secrets.GIT_AUTHOR_EMAIL }} 62 | -------------------------------------------------------------------------------- /tests/Unit/ScoutApmBundle/DependencyInjection/ScoutApmExtensionTest.php: -------------------------------------------------------------------------------- 1 | 'My Symfony App', 27 | 'key' => 'some application key', 28 | 'monitor' => true, 29 | ]; 30 | 31 | (new ScoutApmExtension())->load( 32 | [ 33 | ['scoutapm' => $scoutApmConfiguration], 34 | ], 35 | $builder 36 | ); 37 | 38 | self::assertTrue($builder->hasDefinition(ScoutApmAgent::class)); 39 | $agentDefinition = $builder->getDefinition(ScoutApmAgent::class); 40 | 41 | self::assertSame([ScoutApmAgentFactory::class, 'createAgent'], $agentDefinition->getFactory()); 42 | 43 | $agentConfigurationArgument = $agentDefinition->getArgument('$agentConfiguration'); 44 | self::assertEquals($scoutApmConfiguration, array_filter($agentConfigurationArgument)); 45 | 46 | self::assertTrue($builder->hasDefinition(InstrumentationListener::class)); 47 | $listener = $builder->getDefinition(InstrumentationListener::class); 48 | 49 | self::assertEquals(['kernel.event_subscriber' => [[]]], $listener->getTags()); 50 | } 51 | 52 | /** @throws Exception */ 53 | public function testLoadPassesEmptyConfigurationAsFactoryParameterWhenNoConfigurationPassedToLoad(): void 54 | { 55 | $builder = new ContainerBuilder(); 56 | 57 | (new ScoutApmExtension())->load([], $builder); 58 | 59 | self::assertTrue($builder->hasDefinition(ScoutApmAgent::class)); 60 | 61 | $agentConfigurationArgument = $builder->getDefinition(ScoutApmAgent::class)->getArgument('$agentConfiguration'); 62 | self::assertEquals([], $agentConfigurationArgument); 63 | 64 | self::assertTrue($builder->hasDefinition(InstrumentationListener::class)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Logger/FilteredLogLevelDecorator.php: -------------------------------------------------------------------------------- 1 | 0, 32 | LogLevel::INFO => 1, 33 | LogLevel::NOTICE => 2, 34 | LogLevel::WARNING => 3, 35 | LogLevel::ERROR => 4, 36 | LogLevel::CRITICAL => 5, 37 | LogLevel::ALERT => 6, 38 | LogLevel::EMERGENCY => 7, 39 | ]; 40 | 41 | /** @var LoggerInterface */ 42 | private $realLogger; 43 | 44 | /** @var int */ 45 | private $minimumLogLevel; 46 | 47 | /** @param string $minimumLogLevel e.g. `emergency`, `error`, etc. - {@see \Psr\Log\LogLevel} */ 48 | public function __construct(LoggerInterface $realLogger, string $minimumLogLevel) 49 | { 50 | try { 51 | Assert::keyExists( 52 | self::LOG_LEVEL_ORDER, 53 | strtolower($minimumLogLevel), 54 | sprintf( 55 | 'Log level %s was not a valid PSR-3 compatible log level, defaulting to %s. Should be one of: %s', 56 | $minimumLogLevel, 57 | Config::DEFAULT_LOG_LEVEL, 58 | implode(', ', array_keys(self::LOG_LEVEL_ORDER)) 59 | ) 60 | ); 61 | } catch (Throwable $e) { 62 | $minimumLogLevel = Config::DEFAULT_LOG_LEVEL; 63 | $realLogger->log( 64 | LogLevel::ERROR, 65 | $e->getMessage(), 66 | ['exception' => $e] 67 | ); 68 | } 69 | 70 | $this->minimumLogLevel = self::LOG_LEVEL_ORDER[strtolower($minimumLogLevel)]; 71 | $this->realLogger = $realLogger; 72 | } 73 | 74 | /** {@inheritDoc} */ 75 | public function log($level, $message, array $context = []): void 76 | { 77 | if ($this->minimumLogLevel > self::LOG_LEVEL_ORDER[$level]) { 78 | return; 79 | } 80 | 81 | $this->realLogger->log($level, self::PREPEND_SCOUT_TAG . $message, $context); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Unit/ScoutApmBundle/DependencyInjection/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'name' => 'My Great Application', 20 | 'monitor' => true, 21 | 'key' => 'abc123', 22 | 'log_level' => null, 23 | 'api_version' => null, 24 | 'ignore' => null, 25 | 'application_root' => null, 26 | 'scm_subdirectory' => null, 27 | 'revision_sha' => null, 28 | 'hostname' => null, 29 | 'core_agent_log_level' => null, 30 | 'core_agent_log_file' => null, 31 | 'core_agent_config_file' => null, 32 | 'core_agent_socket_path' => null, 33 | 'core_agent_dir' => null, 34 | 'core_agent_full_name' => null, 35 | 'core_agent_download_url' => null, 36 | 'core_agent_launch' => null, 37 | 'core_agent_download' => null, 38 | 'core_agent_version' => null, 39 | 'core_agent_triple' => null, 40 | 'core_agent_permissions' => null, 41 | 'disabled_instruments' => null, 42 | 'log_payload_content' => null, 43 | 'uri_reporting' => null, 44 | 'uri_filtered_params' => null, 45 | 'errors_enabled' => null, 46 | 'errors_ignored_exceptions' => null, 47 | 'errors_host' => null, 48 | 'errors_batch_size' => null, 49 | 'errors_filtered_params' => null, 50 | ], 51 | ], 52 | (new Processor())->processConfiguration( 53 | new Configuration(), 54 | [ 55 | [ 56 | 'scoutapm' => [ 57 | 'name' => 'My Great Application', 58 | 'monitor' => true, 59 | 'key' => 'abc123', 60 | ], 61 | ], 62 | ] 63 | ) 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/psalm-stubs.php: -------------------------------------------------------------------------------- 1 | */ 12 | function scoutapm_get_calls(): array 13 | { 14 | } 15 | 16 | /** @return list */ 17 | function scoutapm_list_instrumented_functions(): array 18 | { 19 | } 20 | } 21 | 22 | namespace Composer { 23 | class InstalledVersions { 24 | /** 25 | * @psalm-return list 26 | */ 27 | public static function getInstalledPackages() 28 | { 29 | } 30 | 31 | /** 32 | * @psalm-return array{ 33 | * name: string, 34 | * version: string, 35 | * reference: string, 36 | * pretty_version: string, 37 | * aliases: string[], 38 | * dev: bool, 39 | * install_path: string 40 | * } 41 | */ 42 | public static function getRootPackage() 43 | { 44 | } 45 | 46 | /** 47 | * @psalm-param string $packageName 48 | * @psalm-return string|null 49 | */ 50 | public static function getReference($packageName) 51 | { 52 | } 53 | 54 | /** 55 | * @psalm-param string $packageName 56 | * @psalm-return string|null 57 | */ 58 | public static function getPrettyVersion($packageName) 59 | { 60 | } 61 | 62 | /** 63 | * @psalm-param string $packageName 64 | * @psalm-param bool $includeDevRequirements 65 | * @psalm-return bool 66 | */ 67 | public static function isInstalled($packageName, $includeDevRequirements = true) 68 | { 69 | } 70 | } 71 | } 72 | 73 | namespace Illuminate\Console\Events { 74 | use Symfony\Component\Console\Input\InputInterface; 75 | use Symfony\Component\Console\Output\OutputInterface; 76 | 77 | class CommandStarting 78 | { 79 | /** @var string|null */ 80 | public $command; 81 | /** @param string|null $command */ 82 | public function __construct($command, InputInterface $input, OutputInterface $output) {} 83 | } 84 | class CommandFinished 85 | { 86 | /** @var string|null */ 87 | public $command; 88 | /** 89 | * @param string|null $command 90 | * @param int $exitCode 91 | */ 92 | public function __construct($command, InputInterface $input, OutputInterface $output, $exitCode) {} 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Helper/Timer.php: -------------------------------------------------------------------------------- 1 | start($override); 35 | } 36 | 37 | public function start(?float $override = null): void 38 | { 39 | $this->start = $override ?? microtime(true); 40 | } 41 | 42 | public function stop(?float $override = null): void 43 | { 44 | $this->stop = $override ?? microtime(true); 45 | } 46 | 47 | public static function utcDateTimeFromFloatTimestamp(float $timestamp): DateTimeImmutable 48 | { 49 | $dateTime = DateTimeImmutable::createFromFormat( 50 | self::MICROTIME_FLOAT_FORMAT, 51 | sprintf(self::FORMAT_FLOAT_TO_6_DECIMAL_PLACES, $timestamp), 52 | new DateTimeZone('UTC') 53 | ); 54 | 55 | Assert::isInstanceOf($dateTime, DateTimeImmutable::class); 56 | 57 | return $dateTime; 58 | } 59 | 60 | /** 61 | * Formats the stop time as a timestamp suitable for sending to CoreAgent 62 | */ 63 | public function getStop(): ?string 64 | { 65 | if ($this->stop === null) { 66 | return null; 67 | } 68 | 69 | return self::utcDateTimeFromFloatTimestamp($this->stop) 70 | ->format(self::FORMAT_FOR_CORE_AGENT); 71 | } 72 | 73 | /** 74 | * Formats the stop time as a timestamp suitable for sending to CoreAgent 75 | */ 76 | public function getStart(): ?string 77 | { 78 | return self::utcDateTimeFromFloatTimestamp($this->start) 79 | ->format(self::FORMAT_FOR_CORE_AGENT); 80 | } 81 | 82 | public function getStartAsMicrotime(): float 83 | { 84 | return $this->start; 85 | } 86 | 87 | /** 88 | * Returns the duration in microseconds. If the timer has not yet been stopped yet, `null` is returned. 89 | */ 90 | public function duration(): ?float 91 | { 92 | if ($this->stop === null) { 93 | return null; 94 | } 95 | 96 | return $this->stop - $this->start; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Unit/Laravel/Middleware/SendRequestToScoutTest.php: -------------------------------------------------------------------------------- 1 | agent = $this->createMock(ScoutApmAgent::class); 35 | $this->logger = $this->createMock(LoggerInterface::class); 36 | 37 | $this->middleware = new SendRequestToScout( 38 | $this->agent, 39 | new FilteredLogLevelDecorator($this->logger, LogLevel::DEBUG) 40 | ); 41 | } 42 | 43 | public function testHandleSendsRequestToScout(): void 44 | { 45 | $expectedResponse = new Response(); 46 | 47 | $this->agent->expects(self::once()) 48 | ->method('send'); 49 | 50 | $this->logger->expects(self::once()) 51 | ->method('log') 52 | ->with(LogLevel::DEBUG, '[Scout] SendRequestToScout succeeded'); 53 | 54 | self::assertSame( 55 | $expectedResponse, 56 | $this->middleware->handle( 57 | new Request(), 58 | static function () use ($expectedResponse) { 59 | return $expectedResponse; 60 | } 61 | ) 62 | ); 63 | } 64 | 65 | public function testHandleDoesNotThrowExceptionWhenAgentSendCausesException(): void 66 | { 67 | $expectedResponse = new Response(); 68 | 69 | $this->agent->expects(self::once()) 70 | ->method('send') 71 | ->willThrowException(new Exception('oh no')); 72 | 73 | $this->logger->expects(self::once()) 74 | ->method('log') 75 | ->with(LogLevel::DEBUG, '[Scout] SendRequestToScout failed: oh no'); 76 | 77 | self::assertSame( 78 | $expectedResponse, 79 | $this->middleware->handle( 80 | new Request(), 81 | static function () use ($expectedResponse) { 82 | return $expectedResponse; 83 | } 84 | ) 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Connector/ConnectionAddress.php: -------------------------------------------------------------------------------- 1 | path = $path; 32 | } 33 | 34 | public static function fromConfig(Config $config): self 35 | { 36 | return new self($config->get(ConfigKey::CORE_AGENT_SOCKET_PATH)); 37 | } 38 | 39 | public function isTcpAddress(): bool 40 | { 41 | return strpos($this->path, self::TCP_ADDRESS_MARKER) === 0; 42 | } 43 | 44 | public function isSocketPath(): bool 45 | { 46 | return ! $this->isTcpAddress(); 47 | } 48 | 49 | public function socketPath(): string 50 | { 51 | if (! $this->isSocketPath()) { 52 | throw new LogicException('Cannot extract socket path from a non-socket address'); 53 | } 54 | 55 | return $this->path; 56 | } 57 | 58 | /** 59 | * @return string[] 60 | * @psalm-return list 61 | */ 62 | private function explodeTcpAddress(): array 63 | { 64 | if (! $this->isTcpAddress()) { 65 | throw new LogicException('Cannot extract TCP address from a non-TCP address'); 66 | } 67 | 68 | return explode(':', substr($this->path, strlen(self::TCP_ADDRESS_MARKER))); 69 | } 70 | 71 | public function tcpBindAddressPort(): string 72 | { 73 | return sprintf('%s:%d', $this->tcpBindAddress(), $this->tcpBindPort()); 74 | } 75 | 76 | public function tcpBindAddress(): string 77 | { 78 | $parts = $this->explodeTcpAddress(); 79 | 80 | if (! array_key_exists(0, $parts) || $parts[0] === '') { 81 | return self::DEFAULT_TCP_ADDRESS; 82 | } 83 | 84 | return $parts[0]; 85 | } 86 | 87 | public function tcpBindPort(): int 88 | { 89 | $parts = $this->explodeTcpAddress(); 90 | 91 | if (! array_key_exists(1, $parts) || $parts[1] === '') { 92 | return self::DEFAULT_TCP_PORT; 93 | } 94 | 95 | return (int) $parts[1]; 96 | } 97 | 98 | public function toString(): string 99 | { 100 | return $this->path; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Laravel/Router/RuntimeDetermineControllerNameStrategy.php: -------------------------------------------------------------------------------- 1 | */ 27 | private $strategies; 28 | 29 | /** @param non-empty-list $possibleStrategies */ 30 | public function __construct(LoggerInterface $logger, array $possibleStrategies) 31 | { 32 | $this->strategies = $possibleStrategies; 33 | $this->logger = $logger; 34 | } 35 | 36 | public function __invoke(Request $request): string 37 | { 38 | $validResolvedControllerNames = array_filter( 39 | array_combine( 40 | array_map( 41 | static function (AutomaticallyDetermineControllerName $strategy): string { 42 | // spl_object_id is appended to support adding multiple implementations from the same 43 | // definition, for example an anonymous class generator. 44 | return get_class($strategy) . '#' . spl_object_id($strategy); 45 | }, 46 | $this->strategies 47 | ), 48 | array_map( 49 | static function (AutomaticallyDetermineControllerName $strategy) use ($request): string { 50 | return $strategy($request); 51 | }, 52 | $this->strategies 53 | ) 54 | ), 55 | static function (string $resolvedControllerName): bool { 56 | return $resolvedControllerName !== self::UNKNOWN_CONTROLLER_NAME; 57 | } 58 | ); 59 | 60 | if (! $validResolvedControllerNames) { 61 | return self::UNKNOWN_CONTROLLER_NAME; 62 | } 63 | 64 | if (count($validResolvedControllerNames) > 1) { 65 | $this->logger->debug( 66 | 'Multiple strategies determined the controller name, first is picked ' . implode(',', $validResolvedControllerNames), 67 | ['resolvedControllerNames' => $validResolvedControllerNames] 68 | ); 69 | } 70 | 71 | return reset($validResolvedControllerNames); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Laravel/Console/Commands/CoreAgent.php: -------------------------------------------------------------------------------- 1 | option('download'); 25 | $shouldLaunch = $this->option('launch'); 26 | 27 | if (! $shouldDownload && ! $shouldLaunch) { 28 | $this->warn('You must specify --download and/or --launch flags'); 29 | 30 | return 1; 31 | } 32 | 33 | if ($shouldDownload) { 34 | if (! $this->downloadIfNeeded($verifier, $downloader)) { 35 | return 1; 36 | } 37 | } 38 | 39 | if (! $shouldLaunch) { 40 | return 0; 41 | } 42 | 43 | if (! $this->launchIfExists($verifier, $launcher)) { 44 | return 1; 45 | } 46 | 47 | return 0; 48 | } 49 | 50 | private function downloadIfNeeded(Verifier $verifier, Downloader $downloader): bool 51 | { 52 | $this->info('Checking if core agent already exists...'); 53 | $coreAgentBinPath = $verifier->verify(); 54 | 55 | if ($coreAgentBinPath !== null) { 56 | $this->warn(sprintf('Core agent already exists at: %s', $coreAgentBinPath)); 57 | 58 | return true; 59 | } 60 | 61 | $this->info('Core agent does not exist, downloading...'); 62 | 63 | $downloader->download(); 64 | 65 | $coreAgentBinPath = $verifier->verify(); 66 | 67 | if ($coreAgentBinPath === null) { 68 | $this->error('Failed to download Core Agent - check the logs'); 69 | 70 | return false; 71 | } 72 | 73 | $this->info(sprintf('Download complete to: %s', $coreAgentBinPath)); 74 | 75 | return true; 76 | } 77 | 78 | private function launchIfExists(Verifier $verifier, Launcher $launcher): bool 79 | { 80 | $coreAgentBinPath = $verifier->verify(); 81 | if ($coreAgentBinPath === null) { 82 | $this->error('Could not verify that Core Agent exists'); 83 | 84 | return false; 85 | } 86 | 87 | $launcher->launch($coreAgentBinPath); 88 | 89 | $this->info(sprintf('Launch of %s completed.', $coreAgentBinPath)); 90 | 91 | return true; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/Unit/CoreAgent/LauncherTest.php: -------------------------------------------------------------------------------- 1 | set(Config\ConfigKey::CORE_AGENT_SOCKET_PATH, $connectionAddress); 34 | 35 | return ConnectionAddress::fromConfig($config); 36 | } 37 | 38 | public function testLaunchingCoreAgentWithInvalidGlibcIsCaught(): void 39 | { 40 | $logger = new TestLogger(); 41 | 42 | $launcher = new Launcher( 43 | $logger, 44 | $this->connectionAddressFromString('socket-path.sock'), 45 | null, 46 | null, 47 | null 48 | ); 49 | 50 | self::assertFalse($launcher->launch(__DIR__ . '/emulated-core-agent-glibc-error.sh')); 51 | $logger->hasDebugThatContains('core-agent currently needs at least glibc 2.18'); 52 | } 53 | 54 | public function testLaunchCoreAgentWithNonZeroExitCodeIsCaught(): void 55 | { 56 | $logger = new TestLogger(); 57 | 58 | $launcher = new Launcher( 59 | $logger, 60 | $this->connectionAddressFromString('socket-path.sock'), 61 | null, 62 | null, 63 | null 64 | ); 65 | 66 | self::assertFalse($launcher->launch(__DIR__ . '/emulated-unknown-error.sh')); 67 | $logger->hasDebugThatContains('core-agent exited with non-zero status. Output: Something bad went wrong'); 68 | } 69 | 70 | public function testCoreAgentCanBeLaunched(): void 71 | { 72 | $logger = new TestLogger(); 73 | 74 | $launcher = new Launcher( 75 | $logger, 76 | $this->connectionAddressFromString('/tmp/socket-path.sock'), 77 | 'TRACE', 78 | '/tmp/core-agent.log', 79 | '/tmp/core-agent-config.ini' 80 | ); 81 | 82 | self::assertTrue($launcher->launch(__DIR__ . '/emulated-happy-path.sh')); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/CoreAgent/Manifest.php: -------------------------------------------------------------------------------- 1 | manifestPath = $manifestPath; 44 | $this->logger = $logger; 45 | 46 | try { 47 | $this->parse(); 48 | } catch (Throwable $e) { 49 | $this->logger->debug( 50 | sprintf('Exception raised whilst parsing manifest: %s', $e->getMessage()), 51 | ['exception' => $e] 52 | ); 53 | $this->valid = false; 54 | } 55 | } 56 | 57 | private function parse(): void 58 | { 59 | $this->logger->info(sprintf('Parsing Core Agent Manifest at "%s"', $this->manifestPath)); 60 | 61 | Assert::fileExists($this->manifestPath); 62 | Assert::file($this->manifestPath); 63 | Assert::readable($this->manifestPath); 64 | 65 | $raw = file_get_contents($this->manifestPath); 66 | $json = json_decode($raw, true); // decode the JSON into an associative array 67 | 68 | if (json_last_error() !== JSON_ERROR_NONE) { 69 | throw new RuntimeException(sprintf('Decoded JSON was null, last JSON error: %s', json_last_error_msg())); 70 | } 71 | 72 | $this->binVersion = $json['core_agent_version']; 73 | $this->binName = $json['core_agent_binary']; 74 | $this->sha256 = $json['core_agent_binary_sha256']; 75 | $this->valid = true; 76 | } 77 | 78 | public function isValid(): bool 79 | { 80 | return $this->valid; 81 | } 82 | 83 | public function hashOfBinary(): string 84 | { 85 | Assert::string($this->sha256); 86 | 87 | return $this->sha256; 88 | } 89 | 90 | public function binaryName(): string 91 | { 92 | Assert::string($this->binName); 93 | 94 | return $this->binName; 95 | } 96 | 97 | public function binaryVersion(): string 98 | { 99 | Assert::stringNotEmpty($this->binVersion); 100 | 101 | return $this->binVersion; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/Unit/Helper/FindApplicationRoot/FindApplicationRootWithConfigOverrideTest.php: -------------------------------------------------------------------------------- 1 | locateFileOrFolder = $this->createMock(LocateFileOrFolder::class); 25 | } 26 | 27 | public function testConfigurationOverridesApplicationRoot(): void 28 | { 29 | $findApplicationRoot = new FindApplicationRootWithConfigOverride( 30 | $this->locateFileOrFolder, 31 | Config::fromArray([Config\ConfigKey::APPLICATION_ROOT => '/my/configured/app/root']), 32 | new SuperglobalsArrays([], [], [], ['DOCUMENT_ROOT' => '/my/document/root/path'], []) 33 | ); 34 | 35 | self::assertSame('/my/configured/app/root', ($findApplicationRoot)()); 36 | } 37 | 38 | public function testComposerJsonLocationCanBeUsedAsApplicationRoot(): void 39 | { 40 | $findApplicationRoot = new FindApplicationRootWithConfigOverride( 41 | $this->locateFileOrFolder, 42 | Config::fromArray([]), 43 | new SuperglobalsArrays([], [], [], ['DOCUMENT_ROOT' => '/my/document/root/path'], []) 44 | ); 45 | 46 | $this->locateFileOrFolder 47 | ->expects(self::once()) 48 | ->method('__invoke') 49 | ->with('composer.json') 50 | ->willReturn('/path/to/composer_json'); 51 | 52 | self::assertSame('/path/to/composer_json', ($findApplicationRoot)()); 53 | } 54 | 55 | public function testMissingDocumentRootInServerWillReturnEmptyString(): void 56 | { 57 | $findApplicationRoot = new FindApplicationRootWithConfigOverride( 58 | $this->locateFileOrFolder, 59 | Config::fromArray([]), 60 | new SuperglobalsArrays([], [], [], [], []) 61 | ); 62 | self::assertSame('', ($findApplicationRoot)()); 63 | } 64 | 65 | public function testDocumentRootIsReturned(): void 66 | { 67 | $findApplicationRoot = new FindApplicationRootWithConfigOverride( 68 | $this->locateFileOrFolder, 69 | Config::fromArray([]), 70 | new SuperglobalsArrays([], [], [], ['DOCUMENT_ROOT' => '/my/document/root/path'], []) 71 | ); 72 | self::assertSame('/my/document/root/path', ($findApplicationRoot)()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Unit/ScoutApmBundle/ScoutApmBundleTest.php: -------------------------------------------------------------------------------- 1 | container = $this->createMock(ContainerInterface::class); 31 | 32 | $this->bundle = new ScoutApmBundle(); 33 | $this->bundle->setContainer($this->container); 34 | } 35 | 36 | public function testBootRegistersWhenContainerHasService(): void 37 | { 38 | $sqlLogger = new DoctrineSqlLogger($this->createMock(ScoutApmAgent::class)); 39 | 40 | $connection = $this->createMock(Connection::class); 41 | 42 | $this->container->expects(self::exactly(2)) 43 | ->method('has') 44 | ->withConsecutive( 45 | [LoggerInterface::class], 46 | ['doctrine.dbal.default_connection'] 47 | ) 48 | ->willReturnOnConsecutiveCalls( 49 | false, 50 | true 51 | ); 52 | 53 | $this->container->expects(self::exactly(2)) 54 | ->method('get') 55 | ->withConsecutive( 56 | [DoctrineSqlLogger::class], 57 | ['doctrine.dbal.default_connection'] 58 | ) 59 | ->willReturnOnConsecutiveCalls( 60 | $sqlLogger, 61 | $connection 62 | ); 63 | 64 | $configuration = new Configuration(); 65 | 66 | $connection->expects(self::once()) 67 | ->method('getConfiguration') 68 | ->willReturn($configuration); 69 | 70 | $this->bundle->boot(); 71 | 72 | self::assertSame($sqlLogger, $configuration->getSQLLogger()); 73 | } 74 | 75 | public function testBootDoesNothingWhenDoctrineDoesNotExist(): void 76 | { 77 | $this->container->expects(self::exactly(2)) 78 | ->method('has') 79 | ->withConsecutive( 80 | [LoggerInterface::class], 81 | ['doctrine.dbal.default_connection'] 82 | ) 83 | ->willReturnOnConsecutiveCalls( 84 | false, 85 | false 86 | ); 87 | 88 | $this->container->expects(self::never()) 89 | ->method('get'); 90 | 91 | $this->bundle->boot(); 92 | } 93 | } 94 | --------------------------------------------------------------------------------