From 8b943dac4d7680c3d64e86235a383d5bb16987a5 Mon Sep 17 00:00:00 2001 From: Michi Hoffmann Date: Mon, 16 Feb 2026 10:21:37 +0100 Subject: [PATCH 01/89] fix(UserDataBag): Handle bracketed IPv6 addresses in setIpAddress (#2007) Co-authored-by: Claude --- src/UserDataBag.php | 19 +++++++++++++++++-- tests/UserDataBagTest.php | 39 ++++++++++++++++++++++++++++----------- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/UserDataBag.php b/src/UserDataBag.php index dcc374f1f1..8a46af60ee 100644 --- a/src/UserDataBag.php +++ b/src/UserDataBag.php @@ -220,8 +220,23 @@ public function getIpAddress(): ?string */ public function setIpAddress(?string $ipAddress): self { - if ($ipAddress !== null && filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) { - throw new \InvalidArgumentException(\sprintf('The "%s" value is not a valid IP address.', $ipAddress)); + if ($ipAddress !== null) { + // Strip brackets from IPv6 addresses (e.g. [::1] -> ::1) + if (strpos($ipAddress, '[') === 0 && substr($ipAddress, -1) === ']') { + $ipAddress = substr($ipAddress, 1, -1); + } + + if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) { + $client = SentrySdk::getCurrentHub()->getClient(); + + if ($client !== null) { + $client->getOptions()->getLoggerOrNullLogger()->debug( + \sprintf('The "%s" value is not a valid IP address.', $ipAddress) + ); + } + + return $this; + } } $this->ipAddress = $ipAddress; diff --git a/tests/UserDataBagTest.php b/tests/UserDataBagTest.php index 8fd8fb0c0b..7ef0ce8919 100644 --- a/tests/UserDataBagTest.php +++ b/tests/UserDataBagTest.php @@ -162,29 +162,46 @@ public static function unexpectedValueForIdFieldDataProvider(): iterable ]; } - public function testConstructorThrowsIfIpAddressArgumentIsInvalid(): void + /** + * @dataProvider bracketedIpv6AddressDataProvider + */ + public function testSetIpAddressStripsBracketsFromIpv6(string $input, string $expected): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The "foo" value is not a valid IP address.'); + $userDataBag = new UserDataBag(); + $userDataBag->setIpAddress($input); - new UserDataBag(null, null, 'foo'); + $this->assertSame($expected, $userDataBag->getIpAddress()); + } + + public static function bracketedIpv6AddressDataProvider(): iterable + { + yield 'IPv6 loopback with brackets' => ['[::1]', '::1']; + yield 'IPv6 full address with brackets' => ['[2001:db8::1]', '2001:db8::1']; + yield 'IPv6 loopback without brackets' => ['::1', '::1']; + yield 'IPv4 address' => ['127.0.0.1', '127.0.0.1']; } - public function testSetIpAddressThrowsIfArgumentIsInvalid(): void + public function testConstructorDoesNotSetInvalidIpAddress(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The "foo" value is not a valid IP address.'); + $userDataBag = new UserDataBag(null, null, 'foo'); + $this->assertNull($userDataBag->getIpAddress()); + } + + public function testSetIpAddressDoesNotSetInvalidIpAddress(): void + { $userDataBag = new UserDataBag(); + $userDataBag->setIpAddress('127.0.0.1'); $userDataBag->setIpAddress('foo'); + + $this->assertSame('127.0.0.1', $userDataBag->getIpAddress()); } - public function testCreateFromIpAddressThrowsIfArgumentIsInvalid(): void + public function testCreateFromIpAddressDoesNotSetInvalidIpAddress(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The "foo" value is not a valid IP address.'); + $userDataBag = UserDataBag::createFromUserIpAddress('foo'); - UserDataBag::createFromUserIpAddress('foo'); + $this->assertNull($userDataBag->getIpAddress()); } public function testMerge(): void From 7a639df098bc955453359875705cbf69a2e697ed Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Feb 2026 10:22:45 +0100 Subject: [PATCH 02/89] ref(metrics): add `traceMetrics` and deprecate `trace_metrics` (#1995) --- src/functions.php | 10 +++++++- tests/Metrics/TraceMetricsTest.php | 40 +++++++++++++++--------------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/functions.php b/src/functions.php index a7412fce76..0836648d12 100644 --- a/src/functions.php +++ b/src/functions.php @@ -375,13 +375,21 @@ function logger(): Logs } /** - * @deprecated use `trace_metrics` instead + * @deprecated use `traceMetrics` instead */ function metrics(): Metrics { return Metrics::getInstance(); } +function traceMetrics(): TraceMetrics +{ + return TraceMetrics::getInstance(); +} + +/** + * @deprecated use `traceMetrics` instead + */ function trace_metrics(): TraceMetrics { return TraceMetrics::getInstance(); diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 72f497002c..972eab9cd8 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -14,7 +14,7 @@ use Sentry\Options; use Sentry\State\HubAdapter; -use function Sentry\trace_metrics; +use function Sentry\traceMetrics; final class TraceMetricsTest extends TestCase { @@ -26,9 +26,9 @@ protected function setUp(): void public function testCounterMetrics(): void { - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -43,8 +43,8 @@ public function testCounterMetrics(): void public function testGaugeMetrics(): void { - trace_metrics()->gauge('test-gauge', 10, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->gauge('test-gauge', 10, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -59,8 +59,8 @@ public function testGaugeMetrics(): void public function testDistributionMetrics(): void { - trace_metrics()->distribution('test-distribution', 10, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->distribution('test-distribution', 10, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $this->assertCount(1, $event->getMetrics()); @@ -75,9 +75,9 @@ public function testDistributionMetrics(): void public function testMetricsBufferFull(): void { for ($i = 0; $i < MetricsAggregator::METRICS_BUFFER_SIZE + 100; ++$i) { - trace_metrics()->count('test', 1, ['foo' => 'bar']); + traceMetrics()->count('test', 1, ['foo' => 'bar']); } - trace_metrics()->flush(); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $metrics = $event->getMetrics(); @@ -90,8 +90,8 @@ public function testEnableMetrics(): void 'enable_metrics' => false, ]), StubTransport::getInstance())); - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertEmpty(StubTransport::$events); } @@ -106,8 +106,8 @@ public function testBeforeSendMetricAltersContent() }, ]), StubTransport::getInstance())); - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -119,8 +119,8 @@ public function testBeforeSendMetricAltersContent() public function testIntType() { - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -134,8 +134,8 @@ public function testIntType() public function testFloatType(): void { - trace_metrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -150,8 +150,8 @@ public function testFloatType(): void public function testInvalidTypeIsDiscarded(): void { // @phpstan-ignore-next-line - trace_metrics()->count('test-count', 'test-value'); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 'test-value'); + traceMetrics()->flush(); $this->assertEmpty(StubTransport::$events); } From 0b2a1fb3dbde2cef7791779dca961a9536b1c73a Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Feb 2026 13:18:27 +0100 Subject: [PATCH 03/89] fix(tracing): ignore baggage sample rate if sentry trace is not present (#2002) --- src/Tracing/Traits/TraceHeaderParserTrait.php | 17 ++++++++--------- tests/State/HubTest.php | 16 ++++++++++++++++ tests/Tracing/TransactionContextTest.php | 7 +++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Tracing/Traits/TraceHeaderParserTrait.php b/src/Tracing/Traits/TraceHeaderParserTrait.php index 56e002d68b..dd30c29a59 100644 --- a/src/Tracing/Traits/TraceHeaderParserTrait.php +++ b/src/Tracing/Traits/TraceHeaderParserTrait.php @@ -65,22 +65,21 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin $samplingContext = DynamicSamplingContext::fromHeader($baggage); - if ($hasSentryTrace && !$samplingContext->hasEntries()) { + if ($hasSentryTrace) { // The request comes from an old SDK which does not support Dynamic Sampling. // Propagate the Dynamic Sampling Context as is, but frozen, even without sentry-* entries. - $samplingContext->freeze(); - $result['dynamicSamplingContext'] = $samplingContext; - } + if (!$samplingContext->hasEntries()) { + $samplingContext->freeze(); + } - if ($hasSentryTrace && $samplingContext->hasEntries()) { // The baggage header contains Dynamic Sampling Context data from an upstream SDK. // Propagate this Dynamic Sampling Context. $result['dynamicSamplingContext'] = $samplingContext; - } - // Store the propagated traces sample rate - if ($samplingContext->has('sample_rate')) { - $result['parentSamplingRate'] = (float) $samplingContext->get('sample_rate'); + // Store the propagated traces sample rate + if ($samplingContext->has('sample_rate')) { + $result['parentSamplingRate'] = (float) $samplingContext->get('sample_rate'); + } } // Store the propagated trace sample rand or generate a new one diff --git a/tests/State/HubTest.php b/tests/State/HubTest.php index 0fb31e2cda..19ec34c48e 100644 --- a/tests/State/HubTest.php +++ b/tests/State/HubTest.php @@ -636,6 +636,22 @@ public function testStartTransactionWithTracesSampler(Options $options, Transact $this->assertSame($expectedSampled, $transaction->getSampled()); } + public function testStartTransactionIgnoresBaggageSampleRateWithoutSentryTrace(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 0.0, + ])); + + $hub = new Hub($client); + $transactionContext = TransactionContext::fromHeaders('', 'sentry-sample_rate=1'); + $transaction = $hub->startTransaction($transactionContext); + + $this->assertFalse($transaction->getSampled()); + } + public static function startTransactionDataProvider(): iterable { yield 'Acceptable float value returned from traces_sampler' => [ diff --git a/tests/Tracing/TransactionContextTest.php b/tests/Tracing/TransactionContextTest.php index 93dd5bfc5f..3f382d39fd 100644 --- a/tests/Tracing/TransactionContextTest.php +++ b/tests/Tracing/TransactionContextTest.php @@ -149,4 +149,11 @@ public function testSampleRandRangeWhenParentNotSampledAndSampleRateProvided(): $this->assertGreaterThanOrEqual(0.4, $sampleRand); $this->assertLessThanOrEqual(0.999999, $sampleRand); } + + public function testParentSamplingRateIsIgnoredWithoutSentryTraceHeader(): void + { + $context = TransactionContext::fromHeaders('', 'sentry-sample_rate=1'); + + $this->assertNull($context->getMetadata()->getParentSamplingRate()); + } } From 1d481fce452f7cebe02b9531fdda7592474affa1 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Feb 2026 14:11:02 +0100 Subject: [PATCH 04/89] feat(transport): use share handle (#1996) --- src/HttpClient/HttpClient.php | 60 ++++++++++++++++++++ src/Options.php | 33 +++++++++++ src/functions.php | 1 + tests/HttpClient/HttpClientTest.php | 87 +++++++++++++++++++++++++++++ tests/OptionsTest.php | 7 +++ 5 files changed, 188 insertions(+) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index fc08233734..52006ee315 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -22,6 +22,13 @@ class HttpClient implements HttpClientInterface */ protected $sdkVersion; + /** + * Either a persistent share handle or a regular share handle, or null if no share handle can be obtained. + * + * @var object|resource|null + */ + private $shareHandle; + public function __construct(string $sdkIdentifier, string $sdkVersion) { $this->sdkIdentifier = $sdkIdentifier; @@ -72,6 +79,12 @@ public function sendRequest(Request $request, Options $options): Response curl_setopt($curlHandle, \CURLOPT_RETURNTRANSFER, true); curl_setopt($curlHandle, \CURLOPT_HEADERFUNCTION, $responseHeaderCallback); curl_setopt($curlHandle, \CURLOPT_HTTP_VERSION, \CURL_HTTP_VERSION_1_1); + if ($options->isShareHandleEnabled()) { + $shareHandle = $this->getShareHandle(); + if ($shareHandle !== null) { + curl_setopt($curlHandle, \CURLOPT_SHARE, $shareHandle); + } + } $httpSslVerifyPeer = $options->getHttpSslVerifyPeer(); if (!$httpSslVerifyPeer) { @@ -125,4 +138,51 @@ public function sendRequest(Request $request, Options $options): Response return new Response($statusCode, $responseHeaders, $error); } + + /** + * Initializes a share handle for CURL requests. If available, it will always try to use a persistent + * share handle first and fall back to a regular share handle in case it's unavailable. + * + * @return object|resource|null a share handle or null if none could be created + */ + private function getShareHandle() + { + if ($this->shareHandle !== null) { + return $this->shareHandle; + } + if (\function_exists('curl_share_init_persistent')) { + $shareOptions = [\CURL_LOCK_DATA_DNS]; + if (\defined('CURL_LOCK_DATA_CONNECT')) { + $shareOptions[] = \CURL_LOCK_DATA_CONNECT; + } + if (\defined('CURL_LOCK_DATA_SSL_SESSION')) { + $shareOptions[] = \CURL_LOCK_DATA_SSL_SESSION; + } + try { + $this->shareHandle = curl_share_init_persistent($shareOptions); + } catch (\Throwable $throwable) { + // don't crash if the share handle cannot be created + $this->shareHandle = null; + } + } + + // If the persistent share handle cannot be created or doesn't exist + if ($this->shareHandle === null) { + try { + $this->shareHandle = curl_share_init(); + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS); + if (\defined('CURL_LOCK_DATA_CONNECT')) { + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT); + } + if (\defined('CURL_LOCK_DATA_SSL_SESSION')) { + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION); + } + } catch (\Throwable $throwable) { + // don't crash if the share handle cannot be created + $this->shareHandle = null; + } + } + + return $this->shareHandle; + } } diff --git a/src/Options.php b/src/Options.php index 39c3d92e4b..3f75ad8973 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1142,6 +1142,37 @@ public function setEnableHttpCompression(bool $enabled): self return $this; } + /** + * Returns whether a shared curl handle should be used or not. + * + * For PHP 8.5 and above, this will use the persistent curl handle. For previous PHP versions, it will use the + * regular share handle. + */ + public function isShareHandleEnabled(): bool + { + /** + * @var bool $shareHandleEnabled + */ + $shareHandleEnabled = $this->options['http_enable_curl_share_handle']; + + return $shareHandleEnabled; + } + + /** + * Sets whether the persistent curl handle should be used or not. + * + * For PHP 8.5 and above, this will use the persistent curl handle. For previous PHP versions, it will use the + * regular share handle. + */ + public function setEnableShareHandle(bool $enabled): self + { + $options = array_merge($this->options, ['http_enable_curl_share_handle' => $enabled]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Gets whether the silenced errors should be captured or not. * @@ -1341,6 +1372,7 @@ private function configureOptions(OptionsResolver $resolver): void 'http_ssl_verify_peer' => true, 'http_ssl_native_ca' => false, 'http_compression' => true, + 'http_enable_curl_share_handle' => true, 'capture_silenced_errors' => false, 'max_request_body_size' => 'medium', 'class_serializers' => [], @@ -1392,6 +1424,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('http_ssl_verify_peer', 'bool'); $resolver->setAllowedTypes('http_ssl_native_ca', 'bool'); $resolver->setAllowedTypes('http_compression', 'bool'); + $resolver->setAllowedTypes('http_enable_curl_share_handle', 'bool'); $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); $resolver->setAllowedTypes('max_request_body_size', 'string'); $resolver->setAllowedTypes('class_serializers', 'array'); diff --git a/src/functions.php b/src/functions.php index 0836648d12..8d4581487b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -42,6 +42,7 @@ * http_proxy_authentication?: string|null, * http_ssl_verify_peer?: bool, * http_timeout?: int|float, + * http_enable_curl_share_handle?: bool, * ignore_exceptions?: array, * ignore_transactions?: array, * in_app_exclude?: array, diff --git a/tests/HttpClient/HttpClientTest.php b/tests/HttpClient/HttpClientTest.php index 583ba7eff8..45dd466e86 100644 --- a/tests/HttpClient/HttpClientTest.php +++ b/tests/HttpClient/HttpClientTest.php @@ -75,6 +75,59 @@ public function testClientMakesUncompressedRequestWhenCompressionDisabled(): voi $this->assertEquals(\strlen($request->getStringBody()), $serverOutput['headers']['Content-Length']); } + public function testClientMakesRequestWhenShareHandleDisabled(): void + { + $testServer = $this->startTestServer(); + + $options = new Options([ + 'dsn' => "http://publicKey@{$testServer}/200", + 'http_enable_curl_share_handle' => false, + ]); + + $request = new Request(); + $request->setStringBody('test'); + + $client = new HttpClient('sentry.php', 'testing'); + $response = $client->sendRequest($request, $options); + + $serverOutput = $this->stopTestServer(); + + $this->assertTrue($response->isSuccess()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($response->getStatusCode(), $serverOutput['status']); + $this->assertEquals($request->getStringBody(), $serverOutput['body']); + $this->assertNull($this->getShareHandleFromClient($client)); + } + + public function testShareHandleIsInitializedOnlyOncePerHttpClientInstance(): void + { + $testServer = $this->startTestServer(); + + $options = new Options([ + 'dsn' => "http://publicKey@{$testServer}/200", + 'http_enable_curl_share_handle' => true, + ]); + + $request = new Request(); + $request->setStringBody('test'); + + $client = new HttpClient('sentry.php', 'testing'); + + $firstResponse = $client->sendRequest($request, $options); + $firstShareHandle = $this->getShareHandleFromClient($client); + + $secondResponse = $client->sendRequest($request, $options); + $secondShareHandle = $this->getShareHandleFromClient($client); + + $this->stopTestServer(); + + $this->assertTrue($firstResponse->isSuccess()); + $this->assertTrue($secondResponse->isSuccess()); + $this->assertNotNull($firstShareHandle); + $this->assertShareHandleHasExpectedType($firstShareHandle); + $this->assertSame($firstShareHandle, $secondShareHandle); + } + public function testClientReturnsBodyAsErrorOnNonSuccessStatusCode(): void { $testServer = $this->startTestServer(); @@ -118,4 +171,38 @@ public function testThrowsExceptionIfRequestDataIsEmpty(): void $client = new HttpClient('sentry.php', 'testing'); $client->sendRequest(new Request(), $options); } + + /** + * @return object|resource|null + */ + private function getShareHandleFromClient(HttpClient $client) + { + $reflectionProperty = new \ReflectionProperty(HttpClient::class, 'shareHandle'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + + return $reflectionProperty->getValue($client); + } + + /** + * @param object|resource $shareHandle + */ + private function assertShareHandleHasExpectedType($shareHandle): void + { + if (\PHP_VERSION_ID < 80000) { + $this->assertTrue(\is_resource($shareHandle)); + + return; + } + + if (\PHP_VERSION_ID >= 80500) { + $this->assertTrue(class_exists('CurlSharePersistentHandle')); + $this->assertInstanceOf(\CurlSharePersistentHandle::class, $shareHandle); + + return; + } + + $this->assertInstanceOf(\CurlShareHandle::class, $shareHandle); + } } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index d229dad8b2..860b88d5a4 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -415,6 +415,13 @@ static function (): void {}, 'setEnableHttpCompression', ]; + yield [ + 'http_enable_curl_share_handle', + false, + 'isShareHandleEnabled', + 'setEnableShareHandle', + ]; + yield [ 'capture_silenced_errors', true, From 8f5115041c3182d6fe77a38a117fb47b44b5de63 Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Mon, 16 Feb 2026 14:35:29 +0100 Subject: [PATCH 05/89] Add handling for 413 upstream response (#2008) --- src/Transport/ResultStatus.php | 11 +++++++++++ tests/Transport/HttpTransportTest.php | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Transport/ResultStatus.php b/src/Transport/ResultStatus.php index 130bc56a57..096ea2dd0c 100644 --- a/src/Transport/ResultStatus.php +++ b/src/Transport/ResultStatus.php @@ -57,6 +57,15 @@ public static function success(): self return self::getInstance('SUCCESS'); } + /** + * Returns an instance of this enum representing the fact that the event + * failed to be sent because the content was too large. + */ + public static function contentTooLarge(): self + { + return self::getInstance('CONTENT_TOO_LARGE'); + } + /** * Returns an instance of this enum representing the fact that the event * failed to be sent because of API rate limiting. @@ -94,6 +103,8 @@ public static function createFromHttpStatusCode(int $statusCode): self switch (true) { case $statusCode >= 200 && $statusCode < 300: return self::success(); + case $statusCode === 413: + return self::contentTooLarge(); case $statusCode === 429: return self::rateLimit(); case $statusCode >= 400 && $statusCode < 500: diff --git a/tests/Transport/HttpTransportTest.php b/tests/Transport/HttpTransportTest.php index 206261eb0c..7eb93cd3ff 100644 --- a/tests/Transport/HttpTransportTest.php +++ b/tests/Transport/HttpTransportTest.php @@ -114,6 +114,18 @@ public static function sendDataProvider(): iterable ], ]; + yield [ + new Response(413, [], ''), + ResultStatus::contentTooLarge(), + false, + [ + 'info' => [ + 'Sending event [%s] to %s [project:%s].', + 'Sent event [%s] to %s [project:%s]. Result: "content_too_large" (status: 413).', + ], + ], + ]; + ClockMock::withClockMock(1644105600); yield [ From 432857e9a26aa0cffa4760862d52d1dd1e69982a Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Feb 2026 14:47:23 +0100 Subject: [PATCH 06/89] Prepare 4.20.0 (#2009) --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 249cfb0938..3c578a2fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # CHANGELOG +## 4.20.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.20.0. + +### Features + +- Add a high-level `flush()` helper that flushes all buffered telemetry resources (logs and trace metrics). [(#1989)](https://github.com/getsentry/sentry-php/pull/1989) +- Add share handles to cURL (persistent for PHP >= 8.5, non-persistent otherwise). [(#1996)](https://github.com/getsentry/sentry-php/pull/1996) +- Handle HTTP 413 responses explicitly with a dedicated `content_too_large` status. [(#2008)](https://github.com/getsentry/sentry-php/pull/2008) + +### Bug Fixes + +- Normalize Spotlight URLs to optionally allow trailing `/stream`. [(#1984)](https://github.com/getsentry/sentry-php/pull/1984) +- Monolog messages are now filtered by their original Monolog level before being mapped to Sentry log levels. [(#1992)](https://github.com/getsentry/sentry-php/pull/1992) +- Handle bracketed IPv6 addresses (such as `[::1]`). [(#2007)](https://github.com/getsentry/sentry-php/pull/2007) +- Ignore propagated baggage `sample_rate` when no `sentry-trace` header is present. [(#2002)](https://github.com/getsentry/sentry-php/pull/2002) + +### Misc + +- Add the `traceMetrics()` helper and deprecate `trace_metrics()`. [(#1995)](https://github.com/getsentry/sentry-php/pull/1995) + ## 4.19.1 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.19.1. From d7264b972e5f87110492376ade1cc20cbc049345 Mon Sep 17 00:00:00 2001 From: stayallive <1090754+stayallive@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:47:54 +0000 Subject: [PATCH 07/89] release: 4.20.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index e54cb3d29d..6ec3119b1b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.19.1'; + public const SDK_VERSION = '4.20.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 9f8eff48cb8f1eb2527aac8c5e55bda0c8498be0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:13:00 +0100 Subject: [PATCH 08/89] chore(deps): bump getsentry/craft from 2.19.0 to 2.21.4 (#2010) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index bff6fefe1e..26bb608d31 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@c6e2f04939b6ee67030588afbb5af76b127d8203 + uses: getsentry/craft@906009a1b771956757e521555b561379307eb667 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 10d2347427dae9ad10d6ce79a32f4a2c4a7b1f23 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 24 Feb 2026 13:33:23 +0100 Subject: [PATCH 09/89] feat: add runtime context to improve support for FrankenPHP and RoadRunner (#2011) --- .github/workflows/ci.yml | 143 +++++++++ composer.json | 3 + src/Logs/Logs.php | 23 +- src/Logs/LogsAggregator.php | 4 +- src/Metrics/MetricsAggregator.php | 5 +- src/Metrics/TraceMetrics.php | 20 +- src/SentrySdk.php | 101 ++++++- src/State/RuntimeContext.php | 72 +++++ src/State/RuntimeContextManager.php | 281 ++++++++++++++++++ src/functions.php | 31 ++ tests/Fixtures/runtime/frankenphp/index.php | 84 ++++++ tests/Fixtures/runtime/roadrunner-worker.php | 138 +++++++++ tests/Fixtures/runtime/roadrunner.rr.yaml | 12 + tests/FunctionsTest.php | 98 ++++++ .../FrankenPhpWorkerModeIntegrationTest.php | 35 +++ .../RoadRunnerWorkerModeIntegrationTest.php | 49 +++ ...meContextWorkerModeIntegrationTestCase.php | 256 ++++++++++++++++ tests/SentrySdkExtension.php | 9 + tests/SentrySdkTest.php | 239 +++++++++++++++ ...ry_fatal_error_increases_memory_limit.phpt | 2 + ...ry_fatal_error_increases_memory_limit.phpt | 2 + ...errors_not_silencable_on_php_8_and_up.phpt | 2 +- ...spects_capture_silenced_errors_option.phpt | 2 + ..._option_regardless_of_error_reporting.phpt | 2 +- ...tegration_respects_error_types_option.phpt | 2 + .../error_handler_captures_fatal_error.phpt | 2 + ...rror_integration_captures_fatal_error.phpt | 2 + ...tegration_respects_error_types_option.phpt | 2 + .../error_handler_captures_fatal_error.phpt | 2 + ...rror_integration_captures_fatal_error.phpt | 2 + ...tegration_respects_error_types_option.phpt | 2 + tests/phpt/serialize_broken_class.phpt | 2 + ..._callable_that_makes_autoloader_throw.phpt | 2 + tests/phpt/test_callable_serialization.phpt | 2 + 34 files changed, 1595 insertions(+), 38 deletions(-) create mode 100644 src/State/RuntimeContext.php create mode 100644 src/State/RuntimeContextManager.php create mode 100644 tests/Fixtures/runtime/frankenphp/index.php create mode 100644 tests/Fixtures/runtime/roadrunner-worker.php create mode 100644 tests/Fixtures/runtime/roadrunner.rr.yaml create mode 100644 tests/Integration/FrankenPhpWorkerModeIntegrationTest.php create mode 100644 tests/Integration/RoadRunnerWorkerModeIntegrationTest.php create mode 100644 tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb51e01f22..2939342fb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +env: + FRANKENPHP_VERSION: v1.11.2 + ROADRUNNER_VERSION: v2025.1.7 + # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value concurrency: @@ -71,6 +75,10 @@ jobs: - name: Remove unused dependencies run: composer remove vimeo/psalm phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update + - name: Remove RoadRunner dependencies on unsupported PHP versions + if: ${{ matrix.os == 'windows-latest' || matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} + run: composer remove spiral/roadrunner-http spiral/roadrunner-worker --dev --no-interaction --no-update + - name: Set phpunit/phpunit version constraint run: composer require phpunit/phpunit:'${{ matrix.php.phpunit }}' --dev --no-interaction --no-update @@ -96,3 +104,138 @@ jobs: - name: Check benchmarks run: vendor/bin/phpbench run --revs=1 --iterations=1 if: ${{ matrix.dependencies == 'highest' && matrix.php.version == '8.4' }} + + runtime-tests-frankenphp: + name: Runtime tests (FrankenPHP) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Determine Composer cache directory + id: composer-cache + run: echo "directory=$(composer config cache-dir)" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ runner.os }}-runtime-frankenphp-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-runtime-frankenphp-composer- + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Install FrankenPHP + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + case "$(uname -m)" in + x86_64) asset="frankenphp-linux-x86_64" ;; + aarch64|arm64) asset="frankenphp-linux-aarch64" ;; + *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; + esac + + digest="$(gh api "repos/php/frankenphp/releases/tags/${FRANKENPHP_VERSION}" --jq ".assets[] | select(.name == \"${asset}\") | .digest")" + + if [ -z "${digest}" ]; then + echo "Unable to resolve digest for ${asset} (${FRANKENPHP_VERSION})." + exit 1 + fi + + gh release download "${FRANKENPHP_VERSION}" \ + --repo php/frankenphp \ + --pattern "${asset}" \ + --output "${asset}" + + echo "${digest#sha256:} ${asset}" | sha256sum --check -- + mkdir -p "${RUNNER_TEMP}/bin" + install -m 0755 "${asset}" "${RUNNER_TEMP}/bin/frankenphp" + echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" + "${RUNNER_TEMP}/bin/frankenphp" version + shell: bash + + - name: Run PHPUnit tests (excluding PHPT) + run: vendor/bin/phpunit tests --test-suffix Test.php --verbose + + runtime-tests-roadrunner: + name: Runtime tests (RoadRunner) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Determine Composer cache directory + id: composer-cache + run: echo "directory=$(composer config cache-dir)" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ runner.os }}-runtime-roadrunner-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-runtime-roadrunner-composer- + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Install RoadRunner + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + case "$(uname -m)" in + x86_64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; + esac + + version_no_prefix="${ROADRUNNER_VERSION#v}" + asset="roadrunner-${version_no_prefix}-linux-${arch}.tar.gz" + + digest="$(gh api "repos/roadrunner-server/roadrunner/releases/tags/${ROADRUNNER_VERSION}" --jq ".assets[] | select(.name == \"${asset}\") | .digest")" + + if [ -z "${digest}" ]; then + echo "Unable to resolve digest for ${asset} (${ROADRUNNER_VERSION})." + exit 1 + fi + + gh release download "${ROADRUNNER_VERSION}" \ + --repo roadrunner-server/roadrunner \ + --pattern "${asset}" \ + --output "${asset}" + + echo "${digest#sha256:} ${asset}" | sha256sum --check -- + tar -xzf "${asset}" --strip-components=1 "${asset%.tar.gz}/rr" + mkdir -p "${RUNNER_TEMP}/bin" + install -m 0755 rr "${RUNNER_TEMP}/bin/rr" + echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" + "${RUNNER_TEMP}/bin/rr" --version + shell: bash + + - name: Run PHPUnit tests (excluding PHPT) + run: vendor/bin/phpunit tests --test-suffix Test.php --verbose + diff --git a/composer.json b/composer.json index 28a1bd1054..b3878c9acb 100644 --- a/composer.json +++ b/composer.json @@ -36,9 +36,12 @@ "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6", "vimeo/psalm": "^4.17" }, "suggest": { diff --git a/src/Logs/Logs.php b/src/Logs/Logs.php index 99bc34e439..f1e0065436 100644 --- a/src/Logs/Logs.php +++ b/src/Logs/Logs.php @@ -5,6 +5,7 @@ namespace Sentry\Logs; use Sentry\EventId; +use Sentry\SentrySdk; class Logs { @@ -13,14 +14,8 @@ class Logs */ private static $instance; - /** - * @var LogsAggregator - */ - private $aggregator; - private function __construct() { - $this->aggregator = new LogsAggregator(); } public static function getInstance(): self @@ -39,7 +34,7 @@ public static function getInstance(): self */ public function trace(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::trace(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::trace(), $message, $values, $attributes); } /** @@ -49,7 +44,7 @@ public function trace(string $message, array $values = [], array $attributes = [ */ public function debug(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::debug(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::debug(), $message, $values, $attributes); } /** @@ -59,7 +54,7 @@ public function debug(string $message, array $values = [], array $attributes = [ */ public function info(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::info(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::info(), $message, $values, $attributes); } /** @@ -69,7 +64,7 @@ public function info(string $message, array $values = [], array $attributes = [] */ public function warn(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::warn(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::warn(), $message, $values, $attributes); } /** @@ -79,7 +74,7 @@ public function warn(string $message, array $values = [], array $attributes = [] */ public function error(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::error(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::error(), $message, $values, $attributes); } /** @@ -89,7 +84,7 @@ public function error(string $message, array $values = [], array $attributes = [ */ public function fatal(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::fatal(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::fatal(), $message, $values, $attributes); } /** @@ -97,7 +92,7 @@ public function fatal(string $message, array $values = [], array $attributes = [ */ public function flush(): ?EventId { - return $this->aggregator->flush(); + return $this->aggregator()->flush(); } /** @@ -107,6 +102,6 @@ public function flush(): ?EventId */ public function aggregator(): LogsAggregator { - return $this->aggregator; + return SentrySdk::getCurrentRuntimeContext()->getLogsAggregator(); } } diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 8d1ab7db49..16a61adb76 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -154,13 +154,13 @@ public function add( $this->logs[] = $log; } - public function flush(): ?EventId + public function flush(?HubInterface $hub = null): ?EventId { if (empty($this->logs)) { return null; } - $hub = SentrySdk::getCurrentHub(); + $hub = $hub ?? SentrySdk::getCurrentHub(); $event = Event::createLogs()->setLogs($this->logs); $this->logs = []; diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index a25df0b35b..46f015be0e 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -12,6 +12,7 @@ use Sentry\Metrics\Types\GaugeMetric; use Sentry\Metrics\Types\Metric; use Sentry\SentrySdk; +use Sentry\State\HubInterface; use Sentry\State\Scope; use Sentry\Unit; use Sentry\Util\RingBuffer; @@ -134,13 +135,13 @@ public function add( $this->metrics->push($metric); } - public function flush(): ?EventId + public function flush(?HubInterface $hub = null): ?EventId { if ($this->metrics->isEmpty()) { return null; } - $hub = SentrySdk::getCurrentHub(); + $hub = $hub ?? SentrySdk::getCurrentHub(); $event = Event::createMetrics()->setMetrics($this->metrics->drain()); return $hub->captureEvent($event); diff --git a/src/Metrics/TraceMetrics.php b/src/Metrics/TraceMetrics.php index a3ef4a0a0b..4eae90d388 100644 --- a/src/Metrics/TraceMetrics.php +++ b/src/Metrics/TraceMetrics.php @@ -8,6 +8,7 @@ use Sentry\Metrics\Types\CounterMetric; use Sentry\Metrics\Types\DistributionMetric; use Sentry\Metrics\Types\GaugeMetric; +use Sentry\SentrySdk; use Sentry\Unit; class TraceMetrics @@ -17,14 +18,8 @@ class TraceMetrics */ private static $instance; - /** - * @var MetricsAggregator - */ - private $aggregator; - public function __construct() { - $this->aggregator = new MetricsAggregator(); } public static function getInstance(): self @@ -46,7 +41,7 @@ public function count( array $attributes = [], ?Unit $unit = null ): void { - $this->aggregator->add( + $this->aggregator()->add( CounterMetric::TYPE, $name, $value, @@ -65,7 +60,7 @@ public function distribution( array $attributes = [], ?Unit $unit = null ): void { - $this->aggregator->add( + $this->aggregator()->add( DistributionMetric::TYPE, $name, $value, @@ -84,7 +79,7 @@ public function gauge( array $attributes = [], ?Unit $unit = null ): void { - $this->aggregator->add( + $this->aggregator()->add( GaugeMetric::TYPE, $name, $value, @@ -95,6 +90,11 @@ public function gauge( public function flush(): ?EventId { - return $this->aggregator->flush(); + return $this->aggregator()->flush(); + } + + private function aggregator(): MetricsAggregator + { + return SentrySdk::getCurrentRuntimeContext()->getMetricsAggregator(); } } diff --git a/src/SentrySdk.php b/src/SentrySdk.php index 7621b44a6d..75b2327083 100644 --- a/src/SentrySdk.php +++ b/src/SentrySdk.php @@ -8,6 +8,8 @@ use Sentry\Metrics\TraceMetrics; use Sentry\State\Hub; use Sentry\State\HubInterface; +use Sentry\State\RuntimeContext; +use Sentry\State\RuntimeContextManager; /** * This class is the main entry point for all the most common SDK features. @@ -17,10 +19,15 @@ final class SentrySdk { /** - * @var HubInterface|null The current hub + * @var HubInterface|null The baseline hub */ private static $currentHub; + /** + * @var RuntimeContextManager|null + */ + private static $runtimeContextManager; + /** * Constructor. */ @@ -35,8 +42,9 @@ private function __construct() public static function init(): HubInterface { self::$currentHub = new Hub(); + self::$runtimeContextManager = new RuntimeContextManager(self::$currentHub); - return self::$currentHub; + return self::getCurrentHub(); } /** @@ -45,25 +53,83 @@ public static function init(): HubInterface */ public static function getCurrentHub(): HubInterface { - if (self::$currentHub === null) { - self::$currentHub = new Hub(); - } - - return self::$currentHub; + return self::getRuntimeContextManager()->getCurrentHub(); } /** * Sets the current hub. * + * If called while an explicit runtime context is active, the hub update is + * scoped to that active context only. Otherwise, it updates the baseline + * hub used by the global fallback context and future contexts. + * * @param HubInterface $hub The hub to set */ public static function setCurrentHub(HubInterface $hub): HubInterface { - self::$currentHub = $hub; + $wasSetOnActiveRuntimeContext = self::getRuntimeContextManager()->setCurrentHub($hub); + + if (!$wasSetOnActiveRuntimeContext) { + self::$currentHub = $hub; + } return $hub; } + public static function startContext(): void + { + self::getRuntimeContextManager()->startContext(); + } + + public static function endContext(?int $timeout = null): void + { + self::getRuntimeContextManager()->endContext($timeout); + } + + /** + * Executes the given callback within an isolated context. + * + * If a context is already active for the current execution key, this method + * reuses it and only executes the callback. + * + * @param callable $callback The callback to execute + * + * @psalm-template T + * + * @psalm-param callable(): T $callback + * + * @return mixed + * + * @psalm-return T + */ + public static function withContext(callable $callback, ?int $timeout = null) + { + $runtimeContextManager = self::getRuntimeContextManager(); + $startedNewContext = !$runtimeContextManager->hasActiveContext(); + + if ($startedNewContext) { + $runtimeContextManager->startContext(); + } + + try { + return $callback(); + } finally { + if ($startedNewContext) { + $runtimeContextManager->endContext($timeout); + } + } + } + + /** + * Gets the current runtime-local context. + * + * @internal + */ + public static function getCurrentRuntimeContext(): RuntimeContext + { + return self::getRuntimeContextManager()->getCurrentContext(); + } + /** * Flushes all buffered telemetry data. * @@ -78,5 +144,24 @@ public static function flush(): void { Logs::getInstance()->flush(); TraceMetrics::getInstance()->flush(); + + $client = self::getCurrentHub()->getClient(); + + if ($client !== null) { + $client->flush(); + } + } + + private static function getRuntimeContextManager(): RuntimeContextManager + { + if (self::$currentHub === null) { + self::$currentHub = new Hub(); + } + + if (self::$runtimeContextManager === null) { + self::$runtimeContextManager = new RuntimeContextManager(self::$currentHub); + } + + return self::$runtimeContextManager; } } diff --git a/src/State/RuntimeContext.php b/src/State/RuntimeContext.php new file mode 100644 index 0000000000..6910cae608 --- /dev/null +++ b/src/State/RuntimeContext.php @@ -0,0 +1,72 @@ +id = $id; + $this->hub = $hub; + $this->logsAggregator = new LogsAggregator(); + $this->metricsAggregator = new MetricsAggregator(); + } + + public function getId(): string + { + return $this->id; + } + + public function getHub(): HubInterface + { + return $this->hub; + } + + public function setHub(HubInterface $hub): void + { + $this->hub = $hub; + } + + public function getLogsAggregator(): LogsAggregator + { + return $this->logsAggregator; + } + + public function getMetricsAggregator(): MetricsAggregator + { + return $this->metricsAggregator; + } +} diff --git a/src/State/RuntimeContextManager.php b/src/State/RuntimeContextManager.php new file mode 100644 index 0000000000..43b46fb65b --- /dev/null +++ b/src/State/RuntimeContextManager.php @@ -0,0 +1,281 @@ + + */ + private $activeContexts = []; + + /** + * @var array + */ + private $executionContextToRuntimeContext = []; + + public function __construct(HubInterface $baseHub) + { + $this->baseHub = $baseHub; + $this->globalContext = null; + } + + /** + * Sets the current hub with context-aware behavior. + * + * If a runtime context is active for the current execution key, the hub is + * updated only for that active context. Otherwise, the baseline/global hub + * template is updated. + * + * @return bool Whether the hub was set on an active runtime context + */ + public function setCurrentHub(HubInterface $hub): bool + { + $executionContextKey = $this->getExecutionContextKey(); + + if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) { + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + $this->activeContexts[$runtimeContextId]->setHub($hub); + + return true; + } + + $this->baseHub = $hub; + + if ($this->globalContext !== null) { + $this->globalContext->setHub($hub); + } + + return false; + } + + public function getCurrentHub(): HubInterface + { + return $this->getCurrentContext()->getHub(); + } + + public function getCurrentContext(): RuntimeContext + { + $executionContextKey = $this->getExecutionContextKey(); + + if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) { + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + + return $this->activeContexts[$runtimeContextId]; + } + + return $this->getGlobalContext(); + } + + public function hasActiveContext(): bool + { + return $this->hasActiveContextForExecutionContextKey($this->getExecutionContextKey()); + } + + /** + * Starts an isolated context for the current execution key. + */ + public function startContext(): void + { + $executionContextKey = $this->getExecutionContextKey(); + + if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) { + // Nested start calls for the same execution key should be a no-op. + return; + } + + $this->createContextForExecutionContextKey($executionContextKey); + } + + /** + * Ends and flushes the active context for the current execution key. + * + * When no context is active for the key this is a no-op. + */ + public function endContext(?int $timeout = null): void + { + $executionContextKey = $this->getExecutionContextKey(); + + if (!$this->hasActiveContextForExecutionContextKey($executionContextKey)) { + return; + } + + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + unset($this->executionContextToRuntimeContext[$executionContextKey]); + + $this->removeContextById($runtimeContextId, $timeout); + } + + private function createContextForExecutionContextKey(string $executionContextKey): void + { + $runtimeContextId = $this->generateRuntimeContextId(); + $runtimeContext = new RuntimeContext($runtimeContextId, $this->createHubFromBaseHub()); + + $this->activeContexts[$runtimeContextId] = $runtimeContext; + $this->executionContextToRuntimeContext[$executionContextKey] = $runtimeContextId; + } + + private function removeContextById(string $runtimeContextId, ?int $timeout = null): void + { + if (!isset($this->activeContexts[$runtimeContextId])) { + return; + } + + $runtimeContext = $this->activeContexts[$runtimeContextId]; + unset($this->activeContexts[$runtimeContextId]); + // Remove any key mappings that may still reference this context. + $this->removeExecutionContextMappingsForRuntimeContext($runtimeContextId); + + $logger = $this->getLoggerFromHub($runtimeContext->getHub()); + + $this->flushRuntimeContextResources($runtimeContext, $timeout, $logger); + } + + private function flushRuntimeContextResources(RuntimeContext $runtimeContext, ?int $timeout, LoggerInterface $logger): void + { + $hub = $runtimeContext->getHub(); + + // captureEvent can throw before transport send (for example from scope event processors + // or before_send callbacks), so we isolate failures and continue flushing other resources. + try { + $runtimeContext->getLogsAggregator()->flush($hub); + } catch (\Throwable $exception) { + $logger->error('Failed to flush logs while ending a runtime context.', [ + 'exception' => $exception, + 'runtime_context_id' => $runtimeContext->getId(), + ]); + } + + // Keep metrics flush independent from logs flush so one bad callback does not block the rest. + try { + $runtimeContext->getMetricsAggregator()->flush($hub); + } catch (\Throwable $exception) { + $logger->error('Failed to flush trace metrics while ending a runtime context.', [ + 'exception' => $exception, + 'runtime_context_id' => $runtimeContext->getId(), + ]); + } + + $client = $hub->getClient(); + + if ($client === null) { + return; + } + + // Custom transports may throw from close(); endContext must stay best-effort and non-fatal. + try { + $client->flush($timeout); + } catch (\Throwable $exception) { + $logger->error('Failed to flush the client transport while ending a runtime context.', [ + 'exception' => $exception, + 'runtime_context_id' => $runtimeContext->getId(), + ]); + } + } + + private function removeExecutionContextMappingsForRuntimeContext(string $runtimeContextId): void + { + foreach ($this->executionContextToRuntimeContext as $executionContextKey => $mappedRuntimeContextId) { + if ($mappedRuntimeContextId === $runtimeContextId) { + unset($this->executionContextToRuntimeContext[$executionContextKey]); + } + } + } + + private function hasActiveContextForExecutionContextKey(string $executionContextKey): bool + { + if (!isset($this->executionContextToRuntimeContext[$executionContextKey])) { + return false; + } + + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + + if (!isset($this->activeContexts[$runtimeContextId])) { + // Mapping points to a context that was already evicted/ended; drop the stale index entry. + unset($this->executionContextToRuntimeContext[$executionContextKey]); + + return false; + } + + return true; + } + + private function createHubFromBaseHub(): HubInterface + { + if (!$this->baseHub instanceof Hub) { + return new Hub($this->baseHub->getClient()); + } + + $clonedScope = null; + + $this->baseHub->configureScope(static function (Scope $scope) use (&$clonedScope): void { + $clonedScope = clone $scope; + // Do not inherit active traces into a new runtime context. + $clonedScope->setSpan(null); + $clonedScope->setPropagationContext(PropagationContext::fromDefaults()); + }); + + return new Hub($this->baseHub->getClient(), $clonedScope ?? new Scope()); + } + + private function getLoggerFromHub(HubInterface $hub): LoggerInterface + { + $client = $hub->getClient(); + + if ($client === null) { + return new NullLogger(); + } + + return $client->getOptions()->getLoggerOrNullLogger(); + } + + private function generateRuntimeContextId(): string + { + return \sprintf('%s-%d', str_replace('.', '', uniqid('', true)), mt_rand()); + } + + private function getExecutionContextKey(): string + { + // All supported runtime modes currently use a process-local execution key. + return self::PROCESS_EXECUTION_CONTEXT_KEY; + } + + private function getGlobalContext(): RuntimeContext + { + if ($this->globalContext === null) { + // Lazy fallback keeps baseline behavior when users do not opt into explicit context lifecycle. + $this->globalContext = new RuntimeContext('global', $this->baseHub); + } + + return $this->globalContext; + } +} diff --git a/src/functions.php b/src/functions.php index 8d4581487b..02b32c430b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -217,6 +217,37 @@ function withScope(callable $callback) return SentrySdk::getCurrentHub()->withScope($callback); } +function startContext(): void +{ + SentrySdk::startContext(); +} + +function endContext(?int $timeout = null): void +{ + SentrySdk::endContext($timeout); +} + +/** + * Executes the given callback within an isolated context. + * + * If a context is already active for the current execution key, it is reused. + * + * @param callable $callback The callback to execute + * @param int|null $timeout The maximum number of seconds to wait while flushing the client transport + * + * @psalm-template T + * + * @psalm-param callable(): T $callback + * + * @return mixed + * + * @psalm-return T + */ +function withContext(callable $callback, ?int $timeout = null) +{ + return SentrySdk::withContext($callback, $timeout); +} + /** * Starts a new `Transaction` and returns it. This is the entry point to manual * tracing instrumentation. diff --git a/tests/Fixtures/runtime/frankenphp/index.php b/tests/Fixtures/runtime/frankenphp/index.php new file mode 100644 index 0000000000..bbcceb5bb3 --- /dev/null +++ b/tests/Fixtures/runtime/frankenphp/index.php @@ -0,0 +1,84 @@ + false, + 'default_integrations' => false, +]); + +configureScope(static function (Scope $scope): void { + $scope->setTag('baseline', 'yes'); +}); + +$handler = static function (): void { + $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', \PHP_URL_PATH); + + if ($path === '/ping') { + header('Content-Type: text/plain'); + echo 'pong'; + + return; + } + + if ($path !== '/scope') { + http_response_code(404); + header('Content-Type: text/plain'); + echo 'not found'; + + return; + } + + $requestTag = isset($_GET['request']) ? (string) $_GET['request'] : 'none'; + $leakTag = isset($_GET['leak']) ? (string) $_GET['leak'] : null; + + withContext(static function () use ($requestTag, $leakTag): void { + configureScope(static function (Scope $scope) use ($requestTag, $leakTag): void { + $scope->setTag('request', $requestTag); + + if ($leakTag !== null) { + $scope->setTag('leak', $leakTag); + } + }); + + $event = Event::createEvent(); + configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $tags = []; + + if ($event !== null) { + $tags = $event->getTags(); + } + + header('Content-Type: application/json'); + echo json_encode([ + 'runtime_context_id' => SentrySdk::getCurrentRuntimeContext()->getId(), + 'traceparent' => getTraceparent(), + 'tags' => $tags, + ]); + }); +}; + +while (true) { + $keepRunning = frankenphp_handle_request($handler); + gc_collect_cycles(); + + if (!$keepRunning) { + break; + } +} diff --git a/tests/Fixtures/runtime/roadrunner-worker.php b/tests/Fixtures/runtime/roadrunner-worker.php new file mode 100644 index 0000000000..6a4680da83 --- /dev/null +++ b/tests/Fixtures/runtime/roadrunner-worker.php @@ -0,0 +1,138 @@ + false, + 'default_integrations' => false, +]); + +configureScope(static function (Scope $scope): void { + $scope->setTag('baseline', 'yes'); +}); + +$factory = new Psr17Factory(); +$worker = Worker::create(); +$psrWorker = createPsrWorker($worker, $factory); + +while (true) { + try { + $request = $psrWorker->waitRequest(); + } catch (Throwable $exception) { + $worker->error((string) $exception); + + continue; + } + + if ($request === null) { + break; + } + + try { + $response = handleRequest($request); + } catch (Throwable $exception) { + $worker->error((string) $exception); + $response = new Response(500, ['Content-Type' => 'text/plain'], 'internal error'); + } + + try { + $psrWorker->respond($response); + } catch (Throwable $exception) { + $worker->error((string) $exception); + } +} + +function createPsrWorker($worker, $factory) +{ + $reflectionClass = new ReflectionClass(PSR7Worker::class); + $constructor = $reflectionClass->getConstructor(); + $requiredParameterCount = $constructor !== null ? $constructor->getNumberOfRequiredParameters() : 0; + + $arguments = [$worker, $factory, $factory, $factory, $factory]; + + return $reflectionClass->newInstanceArgs(array_slice($arguments, 0, $requiredParameterCount)); +} + +function handleRequest($request): Response +{ + $path = $request->getUri()->getPath(); + + if ($path === '/ping') { + return new Response(200, ['Content-Type' => 'text/plain'], 'pong'); + } + + if ($path !== '/scope') { + return new Response(404, ['Content-Type' => 'text/plain'], 'not found'); + } + + $query = []; + parse_str($request->getUri()->getQuery(), $query); + + $requestTag = isset($query['request']) ? (string) $query['request'] : 'none'; + $leakTag = isset($query['leak']) ? (string) $query['leak'] : null; + + $payload = withContext(static function () use ($requestTag, $leakTag): string { + configureScope(static function (Scope $scope) use ($requestTag, $leakTag): void { + $scope->setTag('request', $requestTag); + + if ($leakTag !== null) { + $scope->setTag('leak', $leakTag); + } + }); + + $event = Event::createEvent(); + configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $tags = []; + + if ($event !== null) { + $tags = $event->getTags(); + } + + $encoded = json_encode([ + 'runtime_context_id' => SentrySdk::getCurrentRuntimeContext()->getId(), + 'traceparent' => getTraceparent(), + 'tags' => $tags, + ]); + + if ($encoded === false) { + return '{}'; + } + + return $encoded; + }); + + return new Response(200, ['Content-Type' => 'application/json'], $payload); +} diff --git a/tests/Fixtures/runtime/roadrunner.rr.yaml b/tests/Fixtures/runtime/roadrunner.rr.yaml new file mode 100644 index 0000000000..8e8f266648 --- /dev/null +++ b/tests/Fixtures/runtime/roadrunner.rr.yaml @@ -0,0 +1,12 @@ +version: "3" + +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: "php roadrunner-worker.php" + +http: + address: 127.0.0.1:8080 + pool: + num_workers: 1 diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index 07e0bd918a..060b89d7c7 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -27,6 +27,8 @@ use Sentry\Tracing\TraceId; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; +use Sentry\Transport\Result; +use Sentry\Transport\ResultStatus; use Sentry\Util\SentryUid; use function Sentry\addBreadcrumb; @@ -37,11 +39,14 @@ use function Sentry\captureMessage; use function Sentry\configureScope; use function Sentry\continueTrace; +use function Sentry\endContext; use function Sentry\getBaggage; use function Sentry\getTraceparent; use function Sentry\init; +use function Sentry\startContext; use function Sentry\startTransaction; use function Sentry\trace; +use function Sentry\withContext; use function Sentry\withMonitor; use function Sentry\withScope; @@ -331,6 +336,99 @@ public function testConfigureScope(): void $this->assertTrue($callbackInvoked); } + public function testStartAndEndContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + + startContext(); + + $requestHub = SentrySdk::getCurrentHub(); + + $this->assertNotSame($globalHub, $requestHub); + + endContext(); + + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testWithContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + + $result = withContext(function () use ($globalHub): string { + $this->assertNotSame($globalHub, SentrySdk::getCurrentHub()); + + return 'ok'; + }); + + $this->assertSame('ok', $result); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testNestedWithContextReusesOuterContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $outerHub = null; + $innerHub = null; + + withContext(function () use (&$outerHub, &$innerHub, $globalHub): void { + $outerHub = SentrySdk::getCurrentHub(); + + configureScope(static function (Scope $scope): void { + $scope->setTag('outer', 'yes'); + }); + + withContext(static function () use (&$innerHub): void { + $innerHub = SentrySdk::getCurrentHub(); + }); + + $event = Event::createEvent(); + + configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $this->assertNotSame($globalHub, SentrySdk::getCurrentHub()); + $this->assertSame('yes', $event->getTags()['outer'] ?? null); + }); + + $this->assertNotNull($outerHub); + $this->assertNotNull($innerHub); + $this->assertSame($outerHub, $innerHub); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testWithContextAlwaysEndsContextWithOptionalTimeout(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options()); + $client->expects($this->once()) + ->method('flush') + ->with(13) + ->willReturn(new Result(ResultStatus::success())); + + SentrySdk::init()->bindClient($client); + + try { + withContext(static function (): void { + throw new \RuntimeException('callback failed'); + }, 13); + + $this->fail('The callback exception should be rethrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + } + public function testStartTransaction(): void { $transactionContext = new TransactionContext('foo'); diff --git a/tests/Integration/FrankenPhpWorkerModeIntegrationTest.php b/tests/Integration/FrankenPhpWorkerModeIntegrationTest.php new file mode 100644 index 0000000000..dfc8947223 --- /dev/null +++ b/tests/Integration/FrankenPhpWorkerModeIntegrationTest.php @@ -0,0 +1,35 @@ +commandIsAvailable('frankenphp version')) { + $this->markTestSkipped('FrankenPHP is not available on PATH.'); + } + } + + protected function startRuntimeServer(): void + { + $serverPort = $this->reserveServerPort(); + $this->setServerPort($serverPort); + + $fixtureRoot = realpath(__DIR__ . '/../Fixtures/runtime/frankenphp'); + + if ($fixtureRoot === false) { + throw new \RuntimeException('Could not resolve FrankenPHP fixture directory.'); + } + + $command = \sprintf( + 'frankenphp php-server --root . --worker index.php --listen 127.0.0.1:%d', + $serverPort + ); + + $this->startServerProcess($command, $fixtureRoot); + $this->waitUntilServerIsReady(); + } +} diff --git a/tests/Integration/RoadRunnerWorkerModeIntegrationTest.php b/tests/Integration/RoadRunnerWorkerModeIntegrationTest.php new file mode 100644 index 0000000000..fac638ebf6 --- /dev/null +++ b/tests/Integration/RoadRunnerWorkerModeIntegrationTest.php @@ -0,0 +1,49 @@ +commandIsAvailable('rr --version')) { + $this->markTestSkipped('RoadRunner binary is not available on PATH.'); + } + + if (!$this->isRoadRunnerPhpWorkerStackAvailable()) { + $this->markTestSkipped('RoadRunner worker classes are missing. Install optional dev deps: spiral/roadrunner-worker, spiral/roadrunner-http, nyholm/psr7.'); + } + } + + protected function startRuntimeServer(): void + { + $httpPort = $this->reserveServerPort(); + $rpcPort = $this->reserveServerPort(); + $this->setServerPort($httpPort); + + $fixtureRoot = realpath(__DIR__ . '/../Fixtures/runtime'); + + if ($fixtureRoot === false) { + throw new \RuntimeException('Could not resolve runtime fixture directory.'); + } + + $command = \sprintf( + 'rr serve -c roadrunner.rr.yaml -o http.address=127.0.0.1:%d -o rpc.listen=tcp://127.0.0.1:%d', + $httpPort, + $rpcPort + ); + + $this->startServerProcess($command, $fixtureRoot); + $this->waitUntilServerIsReady(); + } + + private function isRoadRunnerPhpWorkerStackAvailable(): bool + { + return class_exists(\Spiral\RoadRunner\Worker::class) + && class_exists(\Spiral\RoadRunner\Http\PSR7Worker::class) + && class_exists(\Nyholm\Psr7\Factory\Psr17Factory::class) + && class_exists(\Nyholm\Psr7\Response::class); + } +} diff --git a/tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php b/tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php new file mode 100644 index 0000000000..1fbbaafca7 --- /dev/null +++ b/tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php @@ -0,0 +1,256 @@ +stopServerProcess(); + + parent::tearDown(); + } + + final public function testWithContextPreventsScopeBleedingAcrossWorkerRequests(): void + { + $this->skipUnlessRuntimeIsAvailable(); + $this->startRuntimeServer(); + + try { + $firstResponse = $this->requestJson('/scope?request=first&leak=first-only'); + $secondResponse = $this->requestJson('/scope?request=second'); + } finally { + $this->stopServerProcess(); + } + + $this->assertSame('yes', $firstResponse['tags']['baseline'] ?? null); + $this->assertSame('yes', $secondResponse['tags']['baseline'] ?? null); + + $this->assertSame('first', $firstResponse['tags']['request'] ?? null); + $this->assertSame('second', $secondResponse['tags']['request'] ?? null); + + $this->assertSame('first-only', $firstResponse['tags']['leak'] ?? null); + $this->assertArrayNotHasKey('leak', $secondResponse['tags']); + + $this->assertNotSame($firstResponse['runtime_context_id'], $secondResponse['runtime_context_id']); + $this->assertNotSame($firstResponse['traceparent'] ?? null, $secondResponse['traceparent'] ?? null); + } + + abstract protected function skipUnlessRuntimeIsAvailable(): void; + + abstract protected function startRuntimeServer(): void; + + final protected function reserveServerPort(): int + { + $server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errorMessage); + + if ($server === false) { + throw new \RuntimeException(\sprintf('Failed allocating a test port: %s', $errorMessage)); + } + + $address = stream_socket_get_name($server, false); + fclose($server); + + if (!\is_string($address)) { + throw new \RuntimeException('Could not determine allocated test port.'); + } + + $parts = explode(':', $address); + $port = (int) array_pop($parts); + + if ($port <= 0) { + throw new \RuntimeException(\sprintf('Invalid allocated test port from address "%s".', $address)); + } + + return $port; + } + + final protected function setServerPort(int $serverPort): void + { + $this->serverPort = $serverPort; + } + + final protected function startServerProcess(string $command, string $workingDirectory): void + { + if ($this->serverProcess !== null) { + throw new \RuntimeException('Server process is already running.'); + } + + $pipes = []; + $this->serverProcess = proc_open( + $command, + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $workingDirectory + ); + + if (!\is_resource($this->serverProcess)) { + throw new \RuntimeException(\sprintf('Unable to start server process with command: %s', $command)); + } + + $this->serverStdout = $pipes[1]; + $this->serverStderr = $pipes[2]; + + stream_set_blocking($this->serverStdout, false); + stream_set_blocking($this->serverStderr, false); + } + + final protected function waitUntilServerIsReady(string $path = '/ping', int $attempts = 200, int $sleepMicros = 50000): void + { + $context = stream_context_create(['http' => ['timeout' => 1]]); + $url = \sprintf('http://127.0.0.1:%d%s', $this->getServerPort(), $path); + + for ($i = 0; $i < $attempts; ++$i) { + $response = @file_get_contents($url, false, $context); + + if ($response === 'pong') { + return; + } + + if ($this->serverProcess === null) { + throw new \RuntimeException('Server process is not running.'); + } + + $status = proc_get_status($this->serverProcess); + + if (!$status['running']) { + throw new \RuntimeException('Server process exited before becoming ready: ' . $this->collectServerOutput()); + } + + usleep($sleepMicros); + } + + throw new \RuntimeException('Timed out waiting for server readiness: ' . $this->collectServerOutput()); + } + + /** + * @return array{runtime_context_id: string, traceparent: string, tags: array} + */ + final protected function requestJson(string $path): array + { + $url = \sprintf('http://127.0.0.1:%d%s', $this->getServerPort(), $path); + $context = stream_context_create(['http' => ['timeout' => 2, 'ignore_errors' => true]]); + $body = @file_get_contents($url, false, $context); + $responseHeaders = $http_response_header ?? []; + + if ($body === false) { + throw new \RuntimeException(\sprintf('Failed HTTP request to %s.', $url)); + } + + $statusLine = $responseHeaders[0] ?? ''; + + if (strpos($statusLine, '200') === false) { + throw new \RuntimeException(\sprintf('Unexpected HTTP status for %s: %s Body: %s', $url, $statusLine, $body)); + } + + $decoded = json_decode($body, true); + + if (!\is_array($decoded)) { + throw new \RuntimeException(\sprintf('Response body was not valid JSON for %s: %s', $url, $body)); + } + + return $decoded; + } + + final protected function stopServerProcess(): void + { + if ($this->serverProcess === null) { + return; + } + + $status = proc_get_status($this->serverProcess); + + if ($status['running']) { + $this->killProcessTree((int) $status['pid']); + } + + proc_close($this->serverProcess); + + if (\is_resource($this->serverStdout)) { + fclose($this->serverStdout); + } + + if (\is_resource($this->serverStderr)) { + fclose($this->serverStderr); + } + + $this->serverProcess = null; + $this->serverStdout = null; + $this->serverStderr = null; + $this->serverPort = null; + } + + final protected function commandIsAvailable(string $command): bool + { + $output = []; + $exitCode = 1; + + exec($command . ' 2>&1', $output, $exitCode); + + return $exitCode === 0; + } + + private function getServerPort(): int + { + if ($this->serverPort === null) { + throw new \RuntimeException('Server port has not been set.'); + } + + return $this->serverPort; + } + + private function collectServerOutput(): string + { + $stdout = ''; + $stderr = ''; + + if (\is_resource($this->serverStdout)) { + $stdout = stream_get_contents($this->serverStdout); + } + + if (\is_resource($this->serverStderr)) { + $stderr = stream_get_contents($this->serverStderr); + } + + return trim($stdout . "\n" . $stderr); + } + + private function killProcessTree(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec(\sprintf('taskkill /pid %d /f /t', $pid)); + } else { + exec(\sprintf('pkill -P %d', $pid)); + exec(\sprintf('kill %d', $pid)); + } + + proc_terminate($this->serverProcess, 9); + } +} diff --git a/tests/SentrySdkExtension.php b/tests/SentrySdkExtension.php index 8637499eec..82ce507612 100644 --- a/tests/SentrySdkExtension.php +++ b/tests/SentrySdkExtension.php @@ -22,6 +22,15 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + $reflectionProperty = new \ReflectionProperty(SentrySdk::class, 'runtimeContextManager'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + $reflectionProperty->setValue(null, null); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(false); + } + $reflectionProperty = new \ReflectionProperty(Scope::class, 'globalEventProcessors'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); diff --git a/tests/SentrySdkTest.php b/tests/SentrySdkTest.php index f2f9d39604..bb3d7e14ac 100644 --- a/tests/SentrySdkTest.php +++ b/tests/SentrySdkTest.php @@ -4,9 +4,18 @@ namespace Sentry\Tests; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Sentry\ClientInterface; +use Sentry\Event; +use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; +use Sentry\State\Scope; +use Sentry\Tracing\Span; +use Sentry\Tracing\SpanContext; +use Sentry\Transport\Result; +use Sentry\Transport\ResultStatus; final class SentrySdkTest extends TestCase { @@ -36,4 +45,234 @@ public function testSetCurrentHub(): void $this->assertSame($hub, SentrySdk::setCurrentHub($hub)); $this->assertSame($hub, SentrySdk::getCurrentHub()); } + + public function testStartAndEndContextIsolateScopeData(): void + { + SentrySdk::init(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { + $scope->setTag('baseline', 'yes'); + }); + + SentrySdk::startContext(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { + $scope->setTag('request', 'yes'); + }); + + SentrySdk::endContext(); + + $event = Event::createEvent(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $this->assertArrayHasKey('baseline', $event->getTags()); + $this->assertArrayNotHasKey('request', $event->getTags()); + } + + public function testStartContextDoesNotInheritBaselineSpan(): void + { + SentrySdk::init(); + + $baselineSpan = new Span(new SpanContext()); + SentrySdk::getCurrentHub()->setSpan($baselineSpan); + + SentrySdk::startContext(); + $contextHub = SentrySdk::getCurrentHub(); + + $this->assertNull($contextHub->getSpan()); + + SentrySdk::endContext(); + + $this->assertSame($baselineSpan, SentrySdk::getCurrentHub()->getSpan()); + } + + public function testStartContextCreatesFreshPropagationContext(): void + { + SentrySdk::init(); + + $globalTraceparent = $this->getCurrentScopeTraceparent(); + + SentrySdk::startContext(); + $firstContextTraceparent = $this->getCurrentScopeTraceparent(); + SentrySdk::endContext(); + + SentrySdk::startContext(); + $secondContextTraceparent = $this->getCurrentScopeTraceparent(); + SentrySdk::endContext(); + + $this->assertNotSame($globalTraceparent, $firstContextTraceparent); + $this->assertNotSame($firstContextTraceparent, $secondContextTraceparent); + } + + public function testWithContextResetsSpanAndTransactionAcrossInvocations(): void + { + SentrySdk::init(); + + SentrySdk::withContext(function (): void { + $transaction = SentrySdk::getCurrentHub()->startTransaction(new \Sentry\Tracing\TransactionContext('request-1')); + SentrySdk::getCurrentHub()->setSpan($transaction); + + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getTransaction()); + }); + + SentrySdk::withContext(function (): void { + $this->assertNull(SentrySdk::getCurrentHub()->getSpan()); + $this->assertNull(SentrySdk::getCurrentHub()->getTransaction()); + }); + } + + public function testNestedStartContextIsNoOp(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + + SentrySdk::startContext(); + $firstContextHub = SentrySdk::getCurrentHub(); + + SentrySdk::startContext(); + $secondContextHub = SentrySdk::getCurrentHub(); + + $this->assertNotSame($globalHub, $firstContextHub); + $this->assertSame($firstContextHub, $secondContextHub); + + SentrySdk::endContext(); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + + SentrySdk::endContext(); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testEndContextFlushesClientTransportWithOptionalTimeout(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options()); + $client->expects($this->once()) + ->method('flush') + ->with(12) + ->willReturn(new Result(ResultStatus::success())); + + SentrySdk::init()->bindClient($client); + + SentrySdk::startContext(); + SentrySdk::endContext(12); + } + + public function testFlushFlushesClientTransport(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('flush') + ->with(null) + ->willReturn(new Result(ResultStatus::success())); + + SentrySdk::init()->bindClient($client); + + SentrySdk::flush(); + } + + public function testWithContextReturnsCallbackResultAndRestoresGlobalHub(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $callbackHub = null; + + $result = SentrySdk::withContext(static function () use (&$callbackHub): string { + $callbackHub = SentrySdk::getCurrentHub(); + + return 'ok'; + }); + + $this->assertSame('ok', $result); + $this->assertNotNull($callbackHub); + $this->assertNotSame($globalHub, $callbackHub); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testNestedWithContextReusesOuterContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $outerHub = null; + $innerHub = null; + $outerContextId = null; + $innerContextId = null; + + SentrySdk::withContext(function () use (&$outerHub, &$innerHub, &$outerContextId, &$innerContextId, $globalHub): void { + $outerHub = SentrySdk::getCurrentHub(); + $outerContextId = SentrySdk::getCurrentRuntimeContext()->getId(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { + $scope->setTag('outer', 'yes'); + }); + + SentrySdk::withContext(static function () use (&$innerHub, &$innerContextId): void { + $innerHub = SentrySdk::getCurrentHub(); + $innerContextId = SentrySdk::getCurrentRuntimeContext()->getId(); + }); + + $event = Event::createEvent(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $this->assertNotSame($globalHub, SentrySdk::getCurrentHub()); + $this->assertSame('yes', $event->getTags()['outer'] ?? null); + $this->assertSame($outerContextId, SentrySdk::getCurrentRuntimeContext()->getId()); + }); + + $this->assertNotNull($outerHub); + $this->assertNotNull($innerHub); + $this->assertNotNull($outerContextId); + $this->assertNotNull($innerContextId); + $this->assertSame($outerHub, $innerHub); + $this->assertSame($outerContextId, $innerContextId); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testWithContextEndsContextWhenCallbackThrows(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $callbackHub = null; + + try { + SentrySdk::withContext(static function () use (&$callbackHub): void { + $callbackHub = SentrySdk::getCurrentHub(); + + throw new \RuntimeException('boom'); + }); + + $this->fail('The callback exception should be rethrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame('boom', $exception->getMessage()); + } + + $this->assertNotNull($callbackHub); + $this->assertNotSame($globalHub, $callbackHub); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + private function getCurrentScopeTraceparent(): string + { + $traceparent = ''; + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use (&$traceparent): void { + $traceparent = $scope->getPropagationContext()->toTraceparent(); + }); + + return $traceparent; + } } diff --git a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt index 321ed418a1..35a32c8381 100644 --- a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt +++ b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -32,6 +32,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $options = new Options([ 'dsn' => 'http://public@example.com/sentry/1', ]); diff --git a/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt index 92e0305881..a547854dab 100644 --- a/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt +++ b/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -24,6 +24,8 @@ use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; use Sentry\Transport\TransportInterface; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $vendor = __DIR__; while (!file_exists($vendor . '/vendor')) { diff --git a/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt b/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt index f52c12932e..fe1cf65e24 100644 --- a/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt +++ b/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt @@ -45,7 +45,7 @@ $transport = new class implements TransportInterface { } }; -error_reporting(E_ALL & ~E_USER_ERROR); +error_reporting(E_ALL & ~E_USER_ERROR & ~E_DEPRECATED & ~E_USER_DEPRECATED); $options = [ 'dsn' => 'http://public@example.com/sentry/1', diff --git a/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt b/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt index 0063489b73..11ffa7c3c8 100644 --- a/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt +++ b/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt @@ -25,6 +25,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt b/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt index 35deedee90..592ebaa37d 100644 --- a/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt +++ b/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt @@ -39,7 +39,7 @@ $transport = new class implements TransportInterface { } }; -error_reporting(E_ALL & ~E_USER_NOTICE & ~E_USER_WARNING & ~E_USER_ERROR); +error_reporting(E_ALL & ~E_USER_NOTICE & ~E_USER_WARNING & ~E_USER_ERROR & ~E_DEPRECATED & ~E_USER_DEPRECATED); $options = [ 'dsn' => 'http://public@example.com/sentry/1', diff --git a/tests/phpt/error_listener_integration_respects_error_types_option.phpt b/tests/phpt/error_listener_integration_respects_error_types_option.phpt index 4c66a5f9aa..20d413ecde 100644 --- a/tests/phpt/error_listener_integration_respects_error_types_option.phpt +++ b/tests/phpt/error_listener_integration_respects_error_types_option.phpt @@ -28,6 +28,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php84/error_handler_captures_fatal_error.phpt b/tests/phpt/php84/error_handler_captures_fatal_error.phpt index 2c4b9143bd..9225fbafe1 100644 --- a/tests/phpt/php84/error_handler_captures_fatal_error.phpt +++ b/tests/phpt/php84/error_handler_captures_fatal_error.phpt @@ -29,6 +29,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt b/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt index 2c8e40c4d7..7b76f6fedb 100644 --- a/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt +++ b/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt b/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt index 6dc65c4349..56620b1c03 100644 --- a/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt +++ b/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php85/error_handler_captures_fatal_error.phpt b/tests/phpt/php85/error_handler_captures_fatal_error.phpt index 4a4fe0e98d..03f77c5891 100644 --- a/tests/phpt/php85/error_handler_captures_fatal_error.phpt +++ b/tests/phpt/php85/error_handler_captures_fatal_error.phpt @@ -29,6 +29,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt b/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt index 88fdd8f5cc..b6c62b636c 100644 --- a/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt +++ b/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt b/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt index 7d74233ce5..dbf4e8356f 100644 --- a/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt +++ b/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/serialize_broken_class.phpt b/tests/phpt/serialize_broken_class.phpt index 0df9ab8c17..14021bc8a7 100644 --- a/tests/phpt/serialize_broken_class.phpt +++ b/tests/phpt/serialize_broken_class.phpt @@ -19,6 +19,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + // issue present itself in backtrace serialization, see: // - https://github.com/getsentry/sentry-php/pull/818 // - https://github.com/getsentry/sentry-symfony/issues/63#issuecomment-493046411 diff --git a/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt b/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt index 57f31ba66f..3ea72c1bb3 100644 --- a/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt +++ b/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt @@ -20,6 +20,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + function testSerialization(int $depth = 3) { $serializer = new Serializer(new Options(), $depth); diff --git a/tests/phpt/test_callable_serialization.phpt b/tests/phpt/test_callable_serialization.phpt index 416628c585..ff5a2d79e4 100644 --- a/tests/phpt/test_callable_serialization.phpt +++ b/tests/phpt/test_callable_serialization.phpt @@ -27,6 +27,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { From 4e60cfebc7074030c3aaf1b36bf584699bbcfd70 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 24 Feb 2026 16:31:51 +0100 Subject: [PATCH 10/89] Prepare 4.21.0 (#2015) --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c578a2fd8..8c75b2c81d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # CHANGELOG +## 4.21.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.21.0. + +### Features + +- Add `RuntimeContext` and context lifecycle helpers for long-lived runtimes such as FrankenPHP and RoadRunner. [(#2011)](https://github.com/getsentry/sentry-php/pull/2011) + +Long-lived worker runtimes keep process memory between requests, which can cause scope data to leak from one request to the next. +`RuntimeContext` isolates SDK state per request and flushes buffered telemetry when the request context ends. +Data configured before a runtime context is started is copied into each new context as baseline scope data. + +Example: + +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', +]); + +$handler = static function (): void { + \Sentry\withContext(static function (): void { + // Handle one request. + }); +}; + +while (frankenphp_handle_request($handler)) {} +``` + +When using a runtime context, manual `\Sentry\flush()` calls are not needed for request teardown. +It is still necessary to finish transactions explicitly. + ## 4.20.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.20.0. From 2bf405fc4d38f00073a7d023cf321e59f614d54c Mon Sep 17 00:00:00 2001 From: stayallive <1090754+stayallive@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:32:51 +0000 Subject: [PATCH 11/89] release: 4.21.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 6ec3119b1b..47f5216df7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.20.0'; + public const SDK_VERSION = '4.21.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From e086e51ae9c3515309f4153f7c9fbcce69bcc743 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:41:16 +0100 Subject: [PATCH 12/89] chore(deps): bump getsentry/craft from 2.21.4 to 2.23.1 (#2019) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 26bb608d31..0cb9d6894f 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@906009a1b771956757e521555b561379307eb667 + uses: getsentry/craft@d4cfac9d25d1fc72c9241e5d22aff559a114e4e9 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From c34bdc2fb339becd3f06721f367440895ea9e2bf Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 9 Mar 2026 10:48:36 +0100 Subject: [PATCH 13/89] feat: add `strict_trace_continuation` support (#2016) --- phpstan-baseline.neon | 5 - src/Options.php | 45 +++- src/Tracing/DynamicSamplingContext.php | 34 ++- src/Tracing/Traits/TraceHeaderParserTrait.php | 56 +++++ src/functions.php | 27 ++- tests/FunctionsTest.php | 73 ++++++ tests/OptionsTest.php | 7 + tests/Tracing/DynamicSamplingContextTest.php | 58 +++++ tests/Tracing/StrictTraceContinuationTest.php | 214 ++++++++++++++++++ 9 files changed, 483 insertions(+), 36 deletions(-) create mode 100644 tests/Tracing/StrictTraceContinuationTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e4ce16ef57..7f2eb603b7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -260,11 +260,6 @@ parameters: count: 1 path: src/Options.php - - - message: "#^Method Sentry\\\\Options\\:\\:isStrictTracePropagationEnabled\\(\\) should return bool but returns mixed\\.$#" - count: 1 - path: src/Options.php - - message: "#^Method Sentry\\\\Options\\:\\:shouldAttachMetricCodeLocations\\(\\) should return bool but returns mixed\\.$#" count: 1 diff --git a/src/Options.php b/src/Options.php index 3f75ad8973..5138b64892 100644 --- a/src/Options.php +++ b/src/Options.php @@ -61,6 +61,12 @@ public function __construct(array $options = []) $this->configureOptions($this->resolver); + // Migrate `strict_trace_propagation` over to `strict_trace_continuation` if not set. + // If both are set, then `strict_trace_continuation` will take precedence. + if (isset($options['strict_trace_propagation']) && !isset($options['strict_trace_continuation'])) { + $options['strict_trace_continuation'] = $options['strict_trace_propagation']; + } + $this->options = $this->resolver->resolve($options); if ($this->options['enable_tracing'] === true && $this->options['traces_sample_rate'] === null) { @@ -775,25 +781,50 @@ public function setTracePropagationTargets(array $tracePropagationTargets): self } /** - * Returns whether strict trace propagation is enabled or not. + * Returns whether strict trace continuation is enabled or not. */ - public function isStrictTracePropagationEnabled(): bool + public function isStrictTraceContinuationEnabled(): bool { - return $this->options['strict_trace_propagation']; + /** + * @var bool $result + */ + $result = $this->options['strict_trace_continuation']; + + return $result; } /** - * Sets if strict trace propagation should be enabled or not. + * Sets if strict trace continuation should be enabled or not. */ - public function enableStrictTracePropagation(bool $strictTracePropagation): self + public function enableStrictTraceContinuation(bool $strictTraceContinuation): self { - $options = array_merge($this->options, ['strict_trace_propagation' => $strictTracePropagation]); + $options = array_merge($this->options, ['strict_trace_continuation' => $strictTraceContinuation]); $this->options = $this->resolver->resolve($options); return $this; } + /** + * Returns whether strict trace propagation is enabled or not. + * + * @deprecated since version 4.21. To be removed in version 5.0. Use `isStrictTraceContinuationEnabled` instead. + */ + public function isStrictTracePropagationEnabled(): bool + { + return $this->isStrictTraceContinuationEnabled(); + } + + /** + * Sets if strict trace propagation should be enabled or not. + * + * @deprecated since version 4.21. To be removed in version 5.0. Use `enableStrictTraceContinuation` instead. + */ + public function enableStrictTracePropagation(bool $strictTracePropagation): self + { + return $this->enableStrictTraceContinuation($strictTracePropagation); + } + /** * Gets a list of default tags for events. * @@ -1352,6 +1383,7 @@ private function configureOptions(OptionsResolver $resolver): void return $metric; }, 'trace_propagation_targets' => null, + 'strict_trace_continuation' => false, 'strict_trace_propagation' => false, 'tags' => [], 'error_types' => null, @@ -1406,6 +1438,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('ignore_exceptions', 'string[]'); $resolver->setAllowedTypes('ignore_transactions', 'string[]'); $resolver->setAllowedTypes('trace_propagation_targets', ['null', 'string[]']); + $resolver->setAllowedTypes('strict_trace_continuation', 'bool'); $resolver->setAllowedTypes('strict_trace_propagation', 'bool'); $resolver->setAllowedTypes('tags', 'string[]'); $resolver->setAllowedTypes('error_types', ['null', 'int']); diff --git a/src/Tracing/DynamicSamplingContext.php b/src/Tracing/DynamicSamplingContext.php index 0304504ce1..abea31a830 100644 --- a/src/Tracing/DynamicSamplingContext.php +++ b/src/Tracing/DynamicSamplingContext.php @@ -167,22 +167,7 @@ public static function fromTransaction(Transaction $transaction, HubInterface $h $client = $hub->getClient(); if ($client !== null) { - $options = $client->getOptions(); - - if ($options->getDsn() !== null && $options->getDsn()->getPublicKey() !== null) { - $samplingContext->set('public_key', $options->getDsn()->getPublicKey()); - } - if ($options->getDsn() !== null && $options->getDsn()->getOrgId() !== null) { - $samplingContext->set('org_id', (string) $options->getDsn()->getOrgId()); - } - - if ($options->getRelease() !== null) { - $samplingContext->set('release', $options->getRelease()); - } - - if ($options->getEnvironment() !== null) { - $samplingContext->set('environment', $options->getEnvironment()); - } + self::setOrgOptions($client->getOptions(), $samplingContext); } if ($transaction->getSampled() !== null) { @@ -208,11 +193,22 @@ public static function fromOptions(Options $options, Scope $scope): self $samplingContext->set('sample_rate', (string) $options->getTracesSampleRate()); } + self::setOrgOptions($options, $samplingContext); + + $samplingContext->freeze(); + + return $samplingContext; + } + + private static function setOrgOptions(Options $options, DynamicSamplingContext $samplingContext): void + { if ($options->getDsn() !== null && $options->getDsn()->getPublicKey() !== null) { $samplingContext->set('public_key', $options->getDsn()->getPublicKey()); } - if ($options->getDsn() !== null && $options->getDsn()->getOrgId() !== null) { + if ($options->getOrgId() !== null) { + $samplingContext->set('org_id', (string) $options->getOrgId()); + } elseif ($options->getDsn() !== null && $options->getDsn()->getOrgId() !== null) { $samplingContext->set('org_id', (string) $options->getDsn()->getOrgId()); } @@ -223,10 +219,6 @@ public static function fromOptions(Options $options, Scope $scope): self if ($options->getEnvironment() !== null) { $samplingContext->set('environment', $options->getEnvironment()); } - - $samplingContext->freeze(); - - return $samplingContext; } /** diff --git a/src/Tracing/Traits/TraceHeaderParserTrait.php b/src/Tracing/Traits/TraceHeaderParserTrait.php index dd30c29a59..1a5c0f7c08 100644 --- a/src/Tracing/Traits/TraceHeaderParserTrait.php +++ b/src/Tracing/Traits/TraceHeaderParserTrait.php @@ -4,6 +4,7 @@ namespace Sentry\Tracing\Traits; +use Sentry\SentrySdk; use Sentry\Tracing\DynamicSamplingContext; use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; @@ -65,6 +66,14 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin $samplingContext = DynamicSamplingContext::fromHeader($baggage); + if ($hasSentryTrace && !self::shouldContinueTrace($samplingContext)) { + $result['traceId'] = null; + $result['parentSpanId'] = null; + $result['parentSampled'] = null; + + return $result; + } + if ($hasSentryTrace) { // The request comes from an old SDK which does not support Dynamic Sampling. // Propagate the Dynamic Sampling Context as is, but frozen, even without sentry-* entries. @@ -102,4 +111,51 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin return $result; } + + private static function shouldContinueTrace(DynamicSamplingContext $samplingContext): bool + { + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + if ($client === null) { + return true; + } + + $options = $client->getOptions(); + $clientOrgId = $options->getOrgId(); + if ($clientOrgId === null && $options->getDsn() !== null) { + $clientOrgId = $options->getDsn()->getOrgId(); + } + + $baggageOrgId = $samplingContext->get('org_id'); + $logger = $options->getLoggerOrNullLogger(); + + // both org IDs are set but are not equals + if ($clientOrgId !== null && $baggageOrgId !== null && ((string) $clientOrgId !== $baggageOrgId)) { + $logger->debug( + \sprintf( + "Starting a new trace because org IDs don't match (incoming baggage org_id: %s, SDK org_id: %s)", + $baggageOrgId, + $clientOrgId + ) + ); + + return false; + } + + // One org ID is not set and strict trace continuation is enabled + if ($options->isStrictTraceContinuationEnabled() && ($clientOrgId === null) !== ($baggageOrgId === null)) { + $logger->debug( + \sprintf( + 'Starting a new trace because strict trace continuation is enabled and one org ID is missing (incoming baggage org_id: %s, SDK org_id: %s)', + $baggageOrgId !== null ? $baggageOrgId : 'none', + $clientOrgId !== null ? (string) $clientOrgId : 'none' + ) + ); + + return false; + } + + return true; + } } diff --git a/src/functions.php b/src/functions.php index 02b32c430b..f146aa44c8 100644 --- a/src/functions.php +++ b/src/functions.php @@ -62,7 +62,7 @@ * server_name?: string, * spotlight?: bool, * spotlight_url?: string, - * strict_trace_propagation?: bool, + * strict_trace_continuation?: bool, * tags?: array, * trace_propagation_targets?: array|null, * traces_sample_rate?: float|int|null, @@ -389,13 +389,32 @@ function getBaggage(): string */ function continueTrace(string $sentryTrace, string $baggage): TransactionContext { + // With the new `strict_trace_continuation`, it's possible that we start two new + // traces if we parse the TransactionContext and PropagationContext from the same + // headers. To make sure the trace is the same, we will create one transaction + // context from headers and copy relevant information over. + $transactionContext = TransactionContext::fromHeaders($sentryTrace, $baggage); + $propagationContext = PropagationContext::fromDefaults(); + $metadata = $transactionContext->getMetadata(); + + $traceId = $transactionContext->getTraceId() ?? $propagationContext->getTraceId(); + $transactionContext->setTraceId($traceId); + $propagationContext->setTraceId($traceId); + + $propagationContext->setParentSpanId($transactionContext->getParentSpanId()); + $propagationContext->setSampleRand($metadata->getSampleRand()); + + $dynamicSamplingContext = $metadata->getDynamicSamplingContext(); + if ($dynamicSamplingContext !== null) { + $propagationContext->setDynamicSamplingContext($dynamicSamplingContext); + } + $hub = SentrySdk::getCurrentHub(); - $hub->configureScope(static function (Scope $scope) use ($sentryTrace, $baggage) { - $propagationContext = PropagationContext::fromHeaders($sentryTrace, $baggage); + $hub->configureScope(static function (Scope $scope) use ($propagationContext): void { $scope->setPropagationContext($propagationContext); }); - return TransactionContext::fromHeaders($sentryTrace, $baggage); + return $transactionContext; } /** diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index 060b89d7c7..b92926d2b7 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -637,4 +637,77 @@ public function testContinueTrace(): void $this->assertTrue($dynamicSamplingContext->isFrozen()); }); } + + public function testContinueTraceWhenOrgMismatch(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transactionContext = continueTrace( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-org_id=2' + ); + + $newTraceId = (string) $transactionContext->getTraceId(); + $newSampleRand = $transactionContext->getMetadata()->getSampleRand(); + + $this->assertNotSame('566e3688a61d4bc888951642d6f14a19', $newTraceId); + $this->assertNotEmpty($newTraceId); + $this->assertNull($transactionContext->getParentSpanId()); + $this->assertNull($transactionContext->getParentSampled()); + $this->assertNull($transactionContext->getMetadata()->getDynamicSamplingContext()); + $this->assertNotNull($newSampleRand); + + configureScope(function (Scope $scope) use ($newTraceId, $newSampleRand): void { + $propagationContext = $scope->getPropagationContext(); + + $this->assertSame($newTraceId, (string) $propagationContext->getTraceId()); + $this->assertNull($propagationContext->getParentSpanId()); + $this->assertNull($propagationContext->getDynamicSamplingContext()); + $this->assertSame($newSampleRand, $propagationContext->getSampleRand()); + }); + } + + public function testContinueTraceWhenOrgMatch(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transactionContext = continueTrace( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-org_id=1' + ); + + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $transactionContext->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $transactionContext->getParentSpanId()); + $this->assertTrue($transactionContext->getParentSampled()); + + configureScope(function (Scope $scope): void { + $propagationContext = $scope->getPropagationContext(); + + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $propagationContext->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $propagationContext->getParentSpanId()); + + $dynamicSamplingContext = $propagationContext->getDynamicSamplingContext(); + + $this->assertNotNull($dynamicSamplingContext); + $this->assertSame('1', $dynamicSamplingContext->get('org_id')); + }); + } } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 860b88d5a4..5c9e498223 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -289,6 +289,13 @@ static function (): void {}, 'setTracePropagationTargets', ]; + yield [ + 'strict_trace_continuation', + true, + 'isStrictTraceContinuationEnabled', + 'enableStrictTraceContinuation', + ]; + yield [ 'strict_trace_propagation', true, diff --git a/tests/Tracing/DynamicSamplingContextTest.php b/tests/Tracing/DynamicSamplingContextTest.php index 90ded1369a..ebbb05080c 100644 --- a/tests/Tracing/DynamicSamplingContextTest.php +++ b/tests/Tracing/DynamicSamplingContextTest.php @@ -153,6 +153,64 @@ public function testFromOptions(): void $this->assertTrue($samplingContext->isFrozen()); } + public function testFromOptionsUsesConfiguredOrgIdOverDsnOrgId(): void + { + $options = new Options([ + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ]); + + $scope = new Scope(); + $samplingContext = DynamicSamplingContext::fromOptions($options, $scope); + + $this->assertSame('2', $samplingContext->get('org_id')); + } + + public function testFromOptionsFallsBackToDsnOrgId(): void + { + $options = new Options([ + 'dsn' => 'http://public@o1.example.com/1', + ]); + + $scope = new Scope(); + $samplingContext = DynamicSamplingContext::fromOptions($options, $scope); + + $this->assertSame('1', $samplingContext->get('org_id')); + } + + public function testFromTransactionUsesConfiguredOrgIdOverDsnOrgId(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ])); + + $hub = new Hub($client); + $transaction = new Transaction(new TransactionContext(), $hub); + $samplingContext = DynamicSamplingContext::fromTransaction($transaction, $hub); + + $this->assertSame('2', $samplingContext->get('org_id')); + } + + public function testFromTransactionFallsBackToDsnOrgId(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'http://public@o1.example.com/1', + ])); + + $hub = new Hub($client); + $transaction = new Transaction(new TransactionContext(), $hub); + $samplingContext = DynamicSamplingContext::fromTransaction($transaction, $hub); + + $this->assertSame('1', $samplingContext->get('org_id')); + } + /** * @dataProvider getEntriesDataProvider */ diff --git a/tests/Tracing/StrictTraceContinuationTest.php b/tests/Tracing/StrictTraceContinuationTest.php new file mode 100644 index 0000000000..a92a1eab7f --- /dev/null +++ b/tests/Tracing/StrictTraceContinuationTest.php @@ -0,0 +1,214 @@ +createMock(ClientInterface::class); + $client->expects($this->exactly(2)) + ->method('getOptions') + ->willReturn($options); + + SentrySdk::setCurrentHub(new Hub($client)); + + $contexts = [ + PropagationContext::fromHeaders(self::SENTRY_TRACE_HEADER, $baggage), + PropagationContext::fromEnvironment(self::SENTRY_TRACE_HEADER, $baggage), + ]; + + foreach ($contexts as $context) { + if ($expectedContinueTrace) { + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $context->getParentSpanId()); + } else { + $this->assertNotSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertNotEmpty((string) $context->getTraceId()); + $this->assertNull($context->getParentSpanId()); + $this->assertNull($context->getDynamicSamplingContext()); + } + } + } + + /** + * @dataProvider strictTraceContinuationDataProvider + */ + public function testTransactionContext(Options $options, string $baggage, bool $expectedContinueTrace): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->exactly(2)) + ->method('getOptions') + ->willReturn($options); + + SentrySdk::setCurrentHub(new Hub($client)); + + $contexts = [ + TransactionContext::fromHeaders(self::SENTRY_TRACE_HEADER, $baggage), + TransactionContext::fromEnvironment(self::SENTRY_TRACE_HEADER, $baggage), + ]; + + foreach ($contexts as $context) { + if ($expectedContinueTrace) { + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $context->getParentSpanId()); + $this->assertTrue($context->getParentSampled()); + } else { + $this->assertNotSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertNull($context->getParentSpanId()); + $this->assertNull($context->getParentSampled()); + $this->assertNull($context->getMetadata()->getDynamicSamplingContext()); + } + } + } + + public static function strictTraceContinuationDataProvider(): \Generator + { + // First 10 Test cases are modelled after: https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 1, + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 1, + ]), + '', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + ]), + '', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 2, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ]), + '', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + ]), + '', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 2, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'dsn' => 'http://public@o1.example.com/1', + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ]), + 'sentry-org_id=2', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 1, + ]), + 'sentry-org_id=01', + false, + ]; + } +} From 7074c49e91fff5a0eaae5822115315e76539a3b3 Mon Sep 17 00:00:00 2001 From: Martin Seitz Date: Thu, 12 Mar 2026 14:51:11 +0100 Subject: [PATCH 14/89] fix: Use sub-second timestamps in logs (#2018) --- src/Monolog/BreadcrumbHandler.php | 7 ++++- tests/Monolog/BreadcrumbHandlerTest.php | 38 +++++++++++++++++++------ tests/Monolog/RecordFactory.php | 10 +++++-- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/Monolog/BreadcrumbHandler.php b/src/Monolog/BreadcrumbHandler.php index bb2b60ea06..fbda73c26d 100644 --- a/src/Monolog/BreadcrumbHandler.php +++ b/src/Monolog/BreadcrumbHandler.php @@ -54,13 +54,18 @@ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bub */ protected function write($record): void { + $datetime = $record['datetime'] ?? null; + $timestamp = $datetime instanceof \DateTimeInterface + ? $datetime->getTimestamp() + (int) $datetime->format('u') / 1000000 + : null; + $breadcrumb = new Breadcrumb( $this->getBreadcrumbLevel($record['level']), $this->getBreadcrumbType($record['level']), $record['channel'], $record['message'], ($record['context'] ?? []) + ($record['extra'] ?? []), - $record['datetime']->getTimestamp() + $timestamp ); $this->hub->addBreadcrumb($breadcrumb); diff --git a/tests/Monolog/BreadcrumbHandlerTest.php b/tests/Monolog/BreadcrumbHandlerTest.php index ffef9e1925..812c5d12fd 100644 --- a/tests/Monolog/BreadcrumbHandlerTest.php +++ b/tests/Monolog/BreadcrumbHandlerTest.php @@ -15,17 +15,19 @@ final class BreadcrumbHandlerTest extends TestCase { /** * @dataProvider handleDataProvider + * + * @param LogRecord|array $record */ public function testHandle($record, Breadcrumb $expectedBreadcrumb): void { $hub = $this->createMock(HubInterface::class); $hub->expects($this->once()) ->method('addBreadcrumb') - ->with($this->callback(function (Breadcrumb $breadcrumb) use ($expectedBreadcrumb, $record): bool { + ->with($this->callback(function (Breadcrumb $breadcrumb) use ($expectedBreadcrumb): bool { $this->assertSame($expectedBreadcrumb->getMessage(), $breadcrumb->getMessage()); $this->assertSame($expectedBreadcrumb->getLevel(), $breadcrumb->getLevel()); $this->assertSame($expectedBreadcrumb->getType(), $breadcrumb->getType()); - $this->assertEquals($record['datetime']->getTimestamp(), $breadcrumb->getTimestamp()); + $this->assertEquals($expectedBreadcrumb->getTimestamp(), $breadcrumb->getTimestamp()); $this->assertSame($expectedBreadcrumb->getCategory(), $breadcrumb->getCategory()); $this->assertEquals($expectedBreadcrumb->getMetadata(), $breadcrumb->getMetadata()); @@ -37,16 +39,19 @@ public function testHandle($record, Breadcrumb $expectedBreadcrumb): void } /** - * @return iterable, Breadcrumb}> + * @return iterable, Breadcrumb}> */ public static function handleDataProvider(): iterable { + $now = new \DateTimeImmutable(); + $defaultBreadcrumb = new Breadcrumb( Breadcrumb::LEVEL_DEBUG, Breadcrumb::TYPE_DEFAULT, 'channel.foo', 'foo bar', - [] + [], + (float) $now->format('U.u') ); $levelsToBeTested = [ @@ -58,31 +63,46 @@ public static function handleDataProvider(): iterable foreach ($levelsToBeTested as $loggerLevel => $breadcrumbLevel) { yield 'with level ' . Logger::getLevelName($loggerLevel) => [ - RecordFactory::create('foo bar', $loggerLevel, 'channel.foo', [], []), + RecordFactory::create('foo bar', $loggerLevel, 'channel.foo', [], [], $now), $defaultBreadcrumb->withLevel($breadcrumbLevel), ]; } yield 'with level ERROR' => [ - RecordFactory::create('foo bar', Logger::ERROR, 'channel.foo', [], []), + RecordFactory::create('foo bar', Logger::ERROR, 'channel.foo', [], [], $now), $defaultBreadcrumb->withLevel(Breadcrumb::LEVEL_ERROR) ->withType(Breadcrumb::TYPE_ERROR), ]; yield 'with level ALERT' => [ - RecordFactory::create('foo bar', Logger::ALERT, 'channel.foo', [], []), + RecordFactory::create('foo bar', Logger::ALERT, 'channel.foo', [], [], $now), $defaultBreadcrumb->withLevel(Breadcrumb::LEVEL_FATAL) ->withType(Breadcrumb::TYPE_ERROR), ]; yield 'with context' => [ - RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', ['context' => ['foo' => 'bar']], []), + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', ['context' => ['foo' => 'bar']], [], $now), $defaultBreadcrumb->withMetadata('context', ['foo' => 'bar']), ]; yield 'with extra' => [ - RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], ['extra' => ['foo' => 'bar']]), + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], ['extra' => ['foo' => 'bar']], $now), $defaultBreadcrumb->withMetadata('extra', ['foo' => 'bar']), ]; + + yield 'with timestamp' => [ + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], [], new \DateTimeImmutable('1970-01-01 00:00:42.1337 UTC')), + $defaultBreadcrumb->withTimestamp(42.1337), + ]; + + yield 'with zero timestamp' => [ + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], [], new \DateTimeImmutable('1970-01-01 00:00:00.000 UTC')), + $defaultBreadcrumb->withTimestamp(0.0), + ]; + + yield 'with negative timestamp' => [ + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], [], new \DateTimeImmutable('1969-12-31 23:59:56.859 UTC')), + $defaultBreadcrumb->withTimestamp(-3.141), + ]; } } diff --git a/tests/Monolog/RecordFactory.php b/tests/Monolog/RecordFactory.php index be22130590..889bb1f548 100644 --- a/tests/Monolog/RecordFactory.php +++ b/tests/Monolog/RecordFactory.php @@ -19,11 +19,15 @@ final class RecordFactory * * @return array|LogRecord */ - public static function create(string $message, int $level, string $channel, array $context = [], array $extra = []) + public static function create(string $message, int $level, string $channel, array $context = [], array $extra = [], ?\DateTimeImmutable $datetime = null) { + if ($datetime === null) { + $datetime = new \DateTimeImmutable(); + } + if (Logger::API >= 3) { return new LogRecord( - new \DateTimeImmutable(), + $datetime, $channel, Logger::toMonologLevel($level), $message, @@ -39,7 +43,7 @@ public static function create(string $message, int $level, string $channel, arra 'level_name' => Logger::getLevelName($level), 'channel' => $channel, 'extra' => $extra, - 'datetime' => new \DateTimeImmutable(), + 'datetime' => $datetime, ]; } } From d6cc8f1cc2bb9c1df80fed2efec6c7800723e067 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:51:41 +0100 Subject: [PATCH 15/89] chore(deps): bump getsentry/craft from 2.23.1 to 2.23.2 (#2023) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 0cb9d6894f..c97ec88abf 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@d4cfac9d25d1fc72c9241e5d22aff559a114e4e9 + uses: getsentry/craft@d630201930c7fe5aee6366ebee19ebb681128512 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 3869fd5ea8209fe5c454f504aa2b664f7719f60b Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Mar 2026 13:33:43 +0100 Subject: [PATCH 16/89] feat(client-reports): Add support for client reports (#1978) Co-authored-by: Cursor Agent --- src/Client.php | 5 +- src/ClientReport/ClientReportAggregator.php | 84 +++++++++++++ src/ClientReport/DiscardedEvent.php | 45 +++++++ src/ClientReport/Reason.php | 102 +++++++++++++++ src/Event.php | 29 +++++ src/EventType.php | 15 +++ .../EnvelopItems/ClientReportItem.php | 31 +++++ src/Serializer/PayloadSerializer.php | 39 +++--- src/Transport/DataCategory.php | 83 ++++++++++++ src/Transport/HttpTransport.php | 36 +++--- .../ClientReportAggregatorTest.php | 118 ++++++++++++++++++ tests/Serializer/PayloadSerializerTest.php | 45 +++++++ tests/StubLogger.php | 38 ++++++ tests/StubTransport.php | 2 +- 14 files changed, 639 insertions(+), 33 deletions(-) create mode 100644 src/ClientReport/ClientReportAggregator.php create mode 100644 src/ClientReport/DiscardedEvent.php create mode 100644 src/ClientReport/Reason.php create mode 100644 src/Serializer/EnvelopItems/ClientReportItem.php create mode 100644 src/Transport/DataCategory.php create mode 100644 tests/ClientReport/ClientReportAggregatorTest.php create mode 100644 tests/StubLogger.php diff --git a/src/Client.php b/src/Client.php index 47f5216df7..4ca37f0ba2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -178,7 +178,10 @@ public function captureException(\Throwable $exception, ?Scope $scope = null, ?E */ public function captureEvent(Event $event, ?EventHint $hint = null, ?Scope $scope = null): ?EventId { - $event = $this->prepareEvent($event, $hint, $scope); + // Client reports don't need to be augmented in the prepareEvent pipeline. + if ($event->getType() !== EventType::clientReport()) { + $event = $this->prepareEvent($event, $hint, $scope); + } if ($event === null) { return null; diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php new file mode 100644 index 0000000000..8045a51b2b --- /dev/null +++ b/src/ClientReport/ClientReportAggregator.php @@ -0,0 +1,84 @@ + [ + * 'example-reason' => 10 + * ] + * ] + *``` + * + * @var array> + */ + private $reports = []; + + public function add(DataCategory $category, Reason $reason, int $quantity): void + { + $category = $category->getValue(); + $reason = $reason->getValue(); + if ($quantity <= 0) { + $client = HubAdapter::getInstance()->getClient(); + if ($client !== null) { + $logger = $client->getOptions()->getLoggerOrNullLogger(); + $logger->debug('Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', [ + 'category' => $category, + 'reason' => $reason, + 'quantity' => $quantity, + ]); + } + + return; + } + $this->reports[$category][$reason] = ($this->reports[$category][$reason] ?? 0) + $quantity; + } + + public function flush(): void + { + if (empty($this->reports)) { + return; + } + $reports = []; + foreach ($this->reports as $category => $reasons) { + foreach ($reasons as $reason => $quantity) { + $reports[] = new DiscardedEvent($category, $reason, $quantity); + } + } + $event = Event::createClientReport(); + $event->setClientReports($reports); + + $client = HubAdapter::getInstance()->getClient(); + + // Reset the client reports only if we successfully sent an event. If it fails it + // can be sent on the next flush, or it gets discarded anyway. + if ($client !== null && $client->captureEvent($event) !== null) { + $this->reports = []; + } + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } +} diff --git a/src/ClientReport/DiscardedEvent.php b/src/ClientReport/DiscardedEvent.php new file mode 100644 index 0000000000..c2982c36d7 --- /dev/null +++ b/src/ClientReport/DiscardedEvent.php @@ -0,0 +1,45 @@ +category = $category; + $this->reason = $reason; + $this->quantity = $quantity; + } + + public function getCategory(): string + { + return $this->category; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function getReason(): string + { + return $this->reason; + } +} diff --git a/src/ClientReport/Reason.php b/src/ClientReport/Reason.php new file mode 100644 index 0000000000..3c66bf7d49 --- /dev/null +++ b/src/ClientReport/Reason.php @@ -0,0 +1,102 @@ + + */ + private static $instances = []; + + public function __construct(string $value) + { + $this->value = $value; + } + + public static function queueOverflow(): self + { + return self::getInstance('queue_overflow'); + } + + public static function cacheOverflow(): self + { + return self::getInstance('cache_overflow'); + } + + public static function bufferOverflow(): self + { + return self::getInstance('buffer_overflow'); + } + + public static function ratelimitBackoff(): self + { + return self::getInstance('ratelimit_backoff'); + } + + public static function networkError(): self + { + return self::getInstance('network_error'); + } + + public static function sampleRate(): self + { + return self::getInstance('sample_rate'); + } + + public static function beforeSend(): self + { + return self::getInstance('before_send'); + } + + public static function eventProcessor(): self + { + return self::getInstance('event_processor'); + } + + public static function sendError(): self + { + return self::getInstance('send_error'); + } + + public static function internalSdkError(): self + { + return self::getInstance('internal_sdk_error'); + } + + public static function insufficientData(): self + { + return self::getInstance('insufficient_data'); + } + + public static function backpressure(): self + { + return self::getInstance('backpressure'); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString() + { + return $this->value; + } + + private static function getInstance(string $value): self + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } +} diff --git a/src/Event.php b/src/Event.php index e2d2a8c0fc..e236c82a79 100644 --- a/src/Event.php +++ b/src/Event.php @@ -4,6 +4,7 @@ namespace Sentry; +use Sentry\ClientReport\DiscardedEvent; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Logs\Log; @@ -210,6 +211,11 @@ final class Event */ private $profile; + /** + * @var DiscardedEvent[] + */ + private $clientReports = []; + private function __construct(?EventId $eventId, EventType $eventType) { $this->id = $eventId ?? EventId::generate(); @@ -252,6 +258,11 @@ public static function createMetrics(?EventId $eventId = null): self return new self($eventId, EventType::metrics()); } + public static function createClientReport(?EventId $eventId = null): self + { + return new self($eventId, EventType::clientReport()); + } + /** * Gets the ID of this event. */ @@ -978,4 +989,22 @@ public function getTraceId(): ?string return null; } + + /** + * @param DiscardedEvent[] $clientReports + */ + public function setClientReports(array $clientReports): self + { + $this->clientReports = $clientReports; + + return $this; + } + + /** + * @return DiscardedEvent[] + */ + public function getClientReports(): array + { + return $this->clientReports; + } } diff --git a/src/EventType.php b/src/EventType.php index 679f96633d..6da2f13fbb 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -52,6 +52,11 @@ public static function metrics(): self return self::getInstance('trace_metric'); } + public static function clientReport(): self + { + return self::getInstance('client_report'); + } + /** * List of all cases on the enum. * @@ -65,6 +70,7 @@ public static function cases(): array self::checkIn(), self::logs(), self::metrics(), + self::clientReport(), ]; } @@ -73,12 +79,21 @@ public function requiresEventId(): bool switch ($this) { case self::metrics(): case self::logs(): + case self::clientReport(): return false; default: return true; } } + /** + * Returns false if rate limiting should not be applied. + */ + public function requiresRateLimiting(): bool + { + return $this !== self::clientReport(); + } + public function __toString(): string { return $this->value; diff --git a/src/Serializer/EnvelopItems/ClientReportItem.php b/src/Serializer/EnvelopItems/ClientReportItem.php new file mode 100644 index 0000000000..c43a9ee0b4 --- /dev/null +++ b/src/Serializer/EnvelopItems/ClientReportItem.php @@ -0,0 +1,31 @@ +getClientReports(); + + $headers = ['type' => 'client_report']; + $body = [ + 'timestamp' => $event->getTimestamp(), + 'discarded_events' => array_map(static function (DiscardedEvent $report) { + return [ + 'category' => $report->getCategory(), + 'reason' => $report->getReason(), + 'quantity' => $report->getQuantity(), + ]; + }, $reports), + ]; + + return \sprintf("%s\n%s", JSON::encode($headers), JSON::encode($body)); + } +} diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index 45de0f29f5..3204f5900c 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -8,6 +8,7 @@ use Sentry\EventType; use Sentry\Options; use Sentry\Serializer\EnvelopItems\CheckInItem; +use Sentry\Serializer\EnvelopItems\ClientReportItem; use Sentry\Serializer\EnvelopItems\EventItem; use Sentry\Serializer\EnvelopItems\LogsItem; use Sentry\Serializer\EnvelopItems\MetricsItem; @@ -39,23 +40,26 @@ public function __construct(Options $options) */ public function serialize(Event $event): string { - // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers - $envelopeHeader = [ - 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'dsn' => (string) $this->options->getDsn(), - 'sdk' => $event->getSdkPayload(), - ]; + $envelopeHeader = null; + if ($event->getType() !== EventType::clientReport()) { + // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers + $envelopeHeader = [ + 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'dsn' => (string) $this->options->getDsn(), + 'sdk' => $event->getSdkPayload(), + ]; - if ($event->getType()->requiresEventId()) { - $envelopeHeader['event_id'] = (string) $event->getId(); - } + if ($event->getType()->requiresEventId()) { + $envelopeHeader['event_id'] = (string) $event->getId(); + } - $dynamicSamplingContext = $event->getSdkMetadata('dynamic_sampling_context'); - if ($dynamicSamplingContext instanceof DynamicSamplingContext) { - $entries = $dynamicSamplingContext->getEntries(); + $dynamicSamplingContext = $event->getSdkMetadata('dynamic_sampling_context'); + if ($dynamicSamplingContext instanceof DynamicSamplingContext) { + $entries = $dynamicSamplingContext->getEntries(); - if (!empty($entries)) { - $envelopeHeader['trace'] = $entries; + if (!empty($entries)) { + $envelopeHeader['trace'] = $entries; + } } } @@ -80,6 +84,13 @@ public function serialize(Event $event): string case EventType::metrics(): $items[] = MetricsItem::toEnvelopeItem($event); break; + case EventType::clientReport(): + $items[] = ClientReportItem::toEnvelopeItem($event); + break; + } + + if ($envelopeHeader === null) { + return \sprintf("{}\n%s", implode("\n", array_filter($items))); } return \sprintf("%s\n%s", JSON::encode($envelopeHeader), implode("\n", array_filter($items))); diff --git a/src/Transport/DataCategory.php b/src/Transport/DataCategory.php new file mode 100644 index 0000000000..b2ee0884e9 --- /dev/null +++ b/src/Transport/DataCategory.php @@ -0,0 +1,83 @@ + + */ + private static $instances = []; + + public function __construct(string $value) + { + $this->value = $value; + } + + public static function error(): self + { + return self::getInstance('error'); + } + + public static function transaction(): self + { + return self::getInstance('transaction'); + } + + // TODO: not sure if this should be called monitor or checkIn. + public static function checkIn(): self + { + return self::getInstance('monitor'); + } + + public static function logItem(): self + { + return self::getInstance('log_item'); + } + + public static function logBytes(): self + { + return self::getInstance('log_byte'); + } + + public static function profile(): self + { + return self::getInstance('profile'); + } + + public static function metric(): self + { + return self::getInstance('trace_metric'); + } + + public static function internal(): self + { + return self::getInstance('internal'); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString() + { + return $this->value; + } + + private static function getInstance(string $value): self + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } +} diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 12666ebd4b..189d7eed8b 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -91,26 +91,28 @@ public function send(Event $event): Result $this->logger->info(\sprintf('Sending %s to %s.', $eventDescription, $targetDescription), ['event' => $event]); $eventType = $event->getType(); - if ($this->rateLimiter->isRateLimited((string) $eventType)) { - $this->logger->warning( - \sprintf('Rate limit exceeded for sending requests of type "%s".', (string) $eventType), - ['event' => $event] - ); - - return new Result(ResultStatus::rateLimit()); - } - - // Since profiles are attached to transaction we have to check separately if they are rate limited. - // We can do this after transactions have been checked because if transactions are rate limited, - // so are profiles but not the other way around. - if ($event->getSdkMetadata('profile') !== null) { - if ($this->rateLimiter->isRateLimited(RateLimiter::DATA_CATEGORY_PROFILE)) { - // Just remove profiling data so the normal transaction can be sent. - $event->setSdkMetadata('profile', null); + if ($eventType->requiresRateLimiting()) { + if ($this->rateLimiter->isRateLimited((string) $eventType)) { $this->logger->warning( - 'Rate limit exceeded for sending requests of type "profile". The profile has been dropped.', + \sprintf('Rate limit exceeded for sending requests of type "%s".', (string) $eventType), ['event' => $event] ); + + return new Result(ResultStatus::rateLimit()); + } + + // Since profiles are attached to transaction we have to check separately if they are rate limited. + // We can do this after transactions have been checked because if transactions are rate limited, + // so are profiles but not the other way around. + if ($event->getSdkMetadata('profile') !== null) { + if ($this->rateLimiter->isRateLimited(RateLimiter::DATA_CATEGORY_PROFILE)) { + // Just remove profiling data so the normal transaction can be sent. + $event->setSdkMetadata('profile', null); + $this->logger->warning( + 'Rate limit exceeded for sending requests of type "profile". The profile has been dropped.', + ['event' => $event] + ); + } } } diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php new file mode 100644 index 0000000000..ce82c4d50a --- /dev/null +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -0,0 +1,118 @@ +bindClient(new Client(new Options([ + 'logger' => StubLogger::getInstance(), + ]), StubTransport::getInstance())); + } + + public function testAddClientReport(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::error(), Reason::beforeSend(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertCount(1, StubTransport::$events); + $reports = StubTransport::$events[0]->getClientReports(); + $this->assertCount(2, $reports); + + $report = $reports[0]; + $this->assertSame(DataCategory::profile()->getValue(), $report->getCategory()); + $this->assertSame(Reason::eventProcessor()->getValue(), $report->getReason()); + $this->assertSame(10, $report->getQuantity()); + + $report = $reports[1]; + $this->assertSame(DataCategory::error()->getValue(), $report->getCategory()); + $this->assertSame(Reason::beforeSend()->getValue(), $report->getReason()); + $this->assertSame(10, $report->getQuantity()); + } + + public function testClientReportAggregation(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertCount(1, StubTransport::$events); + $reports = StubTransport::$events[0]->getClientReports(); + $this->assertCount(1, $reports); + + $report = $reports[0]; + $this->assertSame(DataCategory::profile()->getValue(), $report->getCategory()); + $this->assertSame(Reason::eventProcessor()->getValue(), $report->getReason()); + $this->assertSame(40, $report->getQuantity()); + } + + public function testFlushDoesNotOverwriteLastEventId(): void + { + $hub = SentrySdk::getCurrentHub(); + $eventId = $hub->captureMessage('foo'); + + $this->assertNotNull($eventId); + $this->assertSame($eventId, $hub->getLastEventId()); + + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertSame($eventId, $hub->getLastEventId()); + } + + public function testNegativeQuantityDiscarded(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertNotEmpty(StubLogger::$logs); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => -10]], StubLogger::$logs[0]); + } + + public function testZeroQuantityDiscarded(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 0); + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => 0]], StubLogger::$logs[0]); + } + + public function testNegativeQuantityDiscardedWhenNoClientIsBound(): void + { + SentrySdk::setCurrentHub(new Hub()); + + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10); + + SentrySdk::setCurrentHub(new Hub(new Client(new Options([ + 'logger' => StubLogger::getInstance(), + ]), StubTransport::getInstance()))); + + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertEmpty(StubLogger::$logs); + } +} diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 53f3235e84..41e3968ec7 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -9,6 +9,7 @@ use Sentry\CheckIn; use Sentry\CheckInStatus; use Sentry\Client; +use Sentry\ClientReport\DiscardedEvent; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Event; @@ -472,5 +473,49 @@ public static function serializeAsEnvelopeDataProvider(): iterable {"items":[{"timestamp":1597790835,"trace_id":"21160e9b836d479f81611368b2aa3d2c","span_id":"d051f34163cd45fb","name":"test-distribution","value":5,"unit":"day","type":"distribution","attributes":{"foo":{"type":"string","value":"bar"}}}]} TEXT ]; + + $event = Event::createClientReport(); + + yield [ + $event, + <<setClientReports([ + new DiscardedEvent('log_item', 'buffer_overflow', 1), + new DiscardedEvent('log_byte', 'buffer_overflow', 256), + ]); + + yield [ + $event, + <<setClientReports([ + new DiscardedEvent('error', 'before_send', 10), + new DiscardedEvent('profile', 'internal_sdk_error', 50), + ]); + + yield [ + $event, + << $level, + 'message' => $message, + 'context' => $context, + ]; + } +} diff --git a/tests/StubTransport.php b/tests/StubTransport.php index 8a427622df..f80d69045a 100644 --- a/tests/StubTransport.php +++ b/tests/StubTransport.php @@ -37,7 +37,7 @@ public function send(Event $event): Result { self::$events[] = $event; - return new Result(ResultStatus::success()); + return new Result(ResultStatus::success(), $event); } public function close(?int $timeout = null): Result From f3bdd39591fc468e15eba9949c8ee9e415e2c347 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Mar 2026 14:03:03 +0100 Subject: [PATCH 17/89] Prepare 4.22.0 (#2025) --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c75b2c81d..ea268f4e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # CHANGELOG +## 4.22.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.22.0. + +### Features + +- Add support for the client report protocol without collecting client reports yet. [(#1978)](https://github.com/getsentry/sentry-php/pull/1978) +- Add `strict_trace_continuation` support to only continue incoming traces when the upstream baggage `org_id` matches the SDK org ID. [(#2016)](https://github.com/getsentry/sentry-php/pull/2016) + +Example: +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'strict_trace_continuation' => true, +]); +``` + +### Bug Fixes + +- Preserve sub-second timestamps for Monolog breadcrumbs. [(#2018)](https://github.com/getsentry/sentry-php/pull/2018) + ## 4.21.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.21.0. From ce6ab95a7021f976a27b4628a4072e481c8acf60 Mon Sep 17 00:00:00 2001 From: Litarnus <8436563+Litarnus@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:03:46 +0000 Subject: [PATCH 18/89] release: 4.22.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 4ca37f0ba2..23f546024a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.21.0'; + public const SDK_VERSION = '4.22.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 9dc8fb0df10f3b4831e1026aa748d303fe03ca3a Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Mar 2026 16:16:31 +0100 Subject: [PATCH 19/89] feat: add AGENTS.md (#2024) --- AGENTS.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 107 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..8c58acfc45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# AGENTS.md + +## Overview + +- `sentry/sentry` is the core PHP SDK, not an application or framework bundle. +- Public entry points include the autoloaded global helpers in + `src/functions.php` and the public classes and interfaces under `src/`. +- Use this file for repo-specific constraints that are easy to miss, and + explore the codebase for current implementation details. + +## Compatibility Rules + +- The minimum supported PHP version for shipped code is `7.2`. + `composer.json` requires `^7.2|^8.0`, so shipped code must remain valid on + PHP `7.2` unless support policy is intentionally being changed. +- This SDK has a broad public API surface. Treat changes to global helpers, + `Options`, `ClientBuilder`, `ClientInterface`, `HubInterface`, + `TransportInterface`, `IntegrationInterface`, tracing/logs/metrics types, and + Monolog handlers as BC-sensitive. +- Preserve the existing cross-version compatibility style. This repo supports + multiple `psr/log`, `symfony/options-resolver`, `guzzlehttp/psr7`, and + `monolog/monolog` major versions. +- Do not assume optional packages or binaries are available. Monolog is only a + suggested dependency, and FrankenPHP/RoadRunner worker coverage depends on + optional binaries and dev dependencies. +- `Spotlight` is treated as an active send path alongside DSN-based delivery. + Do not gate runtime setup or transport behavior on DSN alone. + +## Editing Guidance + +- Keep `declare(strict_types=1);` in PHP files. +- Follow the existing formatting rules from `.php-cs-fixer.dist.php`. +- If you add or change an SDK option, update the resolver/defaults in + `Options`, the relevant getters and setters, the `init()` option array-shape + docs in `src/functions.php`, and the affected tests. +- `src/functions.php` is autoloaded and part of the public API. Keep helper + signatures, phpdoc, and runtime behavior synchronized with the underlying + client, hub, and runtime-context implementation. Functions in + `src/functions.php` should use camelCase naming. +- `IntegrationRegistry` intentionally calls `setupOnce()` only once per + integration class during the process lifetime. Preserve de-duplication and + default-integration gating when changing integration setup behavior. +- `ErrorHandler` has fragile register-once, previous-handler chaining, reserved + memory, and out-of-memory behavior. Preserve that lifecycle carefully and add + PHPT coverage when changing fatal or silenced error handling. +- `SentrySdk::startContext()`, `endContext()`, and `withContext()` must keep + runtime-context isolation and best-effort flushing intact for logs, metrics, + and transport in long-running worker scenarios. +- `HttpTransport` and `PayloadSerializer` are tightly coupled. Preserve the + envelope item selection, Spotlight delivery path, dynamic sampling context, + and the transaction/profile relationship when changing transport or + serialization behavior. +- Monolog support spans multiple Monolog major versions through the + compatibility traits and handlers under `src/Monolog/`. Preserve that + compatibility style when changing logging integrations. +- `Client::SDK_VERSION` is updated by the release action via + `scripts/bump-version.sh`. Do not modify it manually as part of normal + development changes. + +## Test Expectations + +- Add tests with every behavior change. This is a library repo with broad + compatibility and regression coverage. +- New tests belong under `tests/`. +- `phpunit.xml.dist` defines a `unit` suite that includes both PHPUnit tests + and `tests/phpt`, plus a separate `oom` suite for `tests/phpt-oom`. +- Prefer targeted PHPUnit runs while iterating. +- After editing files, run the relevant formatting, lint, and test commands for + the code you changed. +- Before handing back substantive code changes, run `composer check` when + feasible and call out anything you could not run. +- If you change error handling, fatal error capture, or PHP-version-specific + behavior, add or update PHPT coverage. +- If you change runtime-context or worker-mode behavior, add or update focused + coverage for the FrankenPHP or RoadRunner paths as appropriate. + +## Tools And Commands + +- `phpstan.neon` only analyzes `src`, uses `phpstan-baseline.neon`, and will + not catch behavior regressions in `tests/` or PHPT coverage. +- `phpunit.xml.dist` is strict about unexpected output, so noisy debug output + will fail tests. +- This repo is a library, so do not expect a runnable application entrypoint. + +## Docs And Release Notes + +- `README.md` and `CHANGELOG.md` are updated manually during releases, so do + not modify them as part of normal development changes. +- If a change may require updates in the separate documentation repo, ask the + user whether to review `../sentry-docs` if that sibling checkout exists. If + it does not exist, ask the user for the local docs path first. If they opt + in, update that repo's `master` branch when safe, use git worktrees to + inspect the relevant docs, and suggest any needed changes to avoid stale + documentation. +- If a change affects installation, configuration, error handling, tracing, + profiling, metrics, logs, or worker-mode behavior, call out the likely + README or release-note follow-up in your summary instead of editing those + files automatically. + +## CI Notes + +- `.github/workflows/ci.yml` runs the PHPUnit compatibility matrix across + Ubuntu and Windows, lowest and highest dependencies, and separate runtime + jobs for FrankenPHP and RoadRunner. +- `.github/workflows/static-analysis.yaml` runs PHP-CS-Fixer, PHPStan, and + Psalm on single recent PHP versions rather than across the full test matrix. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From fa3bb5d2f583fe74c9fac7851587646a7932610a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:06:52 +0100 Subject: [PATCH 20/89] chore(deps): bump getsentry/craft from 2.23.2 to 2.24.2 (#2027) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index c97ec88abf..2590566f79 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@d630201930c7fe5aee6366ebee19ebb681128512 + uses: getsentry/craft@78da70b88de5cf6245d5d7e3263a9e8952667dec env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From e79c3bd65725dad5e520e6c6b8ef5d44a91a654f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:07:02 +0100 Subject: [PATCH 21/89] chore(deps): bump actions/create-github-app-token from 2.2.1 to 3.0.0 (#2028) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 2590566f79..cf172211ef 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -24,7 +24,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From c63aae7dc17e051730c2e8f3c39da6aa8a4a0a6e Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 19 Mar 2026 20:49:07 +0100 Subject: [PATCH 22/89] ci: remove psalm and replace annotations with phpstan (#2031) --- .github/workflows/ci.yml | 2 +- .github/workflows/static-analysis.yaml | 17 --- composer.json | 7 +- psalm-baseline.xml | 100 ------------------ psalm.xml.dist | 75 ------------- src/Breadcrumb.php | 2 +- src/Client.php | 6 +- src/ClientInterface.php | 6 +- src/ErrorHandler.php | 18 ++-- src/Event.php | 6 +- src/EventHint.php | 2 +- src/FrameBuilder.php | 6 +- .../FrameContextifierIntegration.php | 2 +- src/Integration/ModulesIntegration.php | 5 +- src/Integration/RequestIntegration.php | 4 +- src/Monolog/BreadcrumbHandler.php | 2 - src/Monolog/LogsHandler.php | 7 -- src/Options.php | 30 +++--- src/SentrySdk.php | 6 +- src/Serializer/EnvelopItems/EventItem.php | 2 +- .../EnvelopItems/TransactionItem.php | 4 +- .../Traits/BreadcrumbSeralizerTrait.php | 2 +- .../Traits/StacktraceFrameSeralizerTrait.php | 2 +- src/StacktraceBuilder.php | 4 +- src/State/HubInterface.php | 12 +-- src/State/Scope.php | 4 +- src/Tracing/GuzzleTracingMiddleware.php | 1 - src/Tracing/Span.php | 2 +- src/functions.php | 12 +-- 29 files changed, 73 insertions(+), 275 deletions(-) delete mode 100644 psalm-baseline.xml delete mode 100644 psalm.xml.dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2939342fb5..fb07996426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: # These dependencies are not used running the tests but can cause deprecation warnings so we remove them before running the tests - name: Remove unused dependencies - run: composer remove vimeo/psalm phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update + run: composer remove phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update - name: Remove RoadRunner dependencies on unsupported PHP versions if: ${{ matrix.os == 'windows-latest' || matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index ba6ffe12cf..0c85d12a60 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -47,20 +47,3 @@ jobs: - name: Run script run: vendor/bin/phpstan analyse - psalm: - name: Psalm - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - - - name: Install dependencies - run: composer update --no-progress --no-interaction --prefer-dist - - - name: Run script - run: vendor/bin/psalm diff --git a/composer.json b/composer.json index b3878c9acb..ba94d3f138 100644 --- a/composer.json +++ b/composer.json @@ -41,8 +41,7 @@ "phpstan/phpstan": "^1.3", "phpunit/phpunit": "^8.5.52|^9.6.34", "spiral/roadrunner-http": "^3.6", - "spiral/roadrunner-worker": "^3.6", - "vimeo/psalm": "^4.17" + "spiral/roadrunner-worker": "^3.6" }, "suggest": { "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." @@ -67,14 +66,12 @@ "check": [ "@cs-check", "@phpstan", - "@psalm", "@tests" ], "tests": "vendor/bin/phpunit --verbose", "cs-check": "vendor/bin/php-cs-fixer fix --verbose --diff --dry-run", "cs-fix": "vendor/bin/php-cs-fixer fix --verbose --diff", - "phpstan": "vendor/bin/phpstan analyse", - "psalm": "vendor/bin/psalm" + "phpstan": "vendor/bin/phpstan analyse" }, "config": { "sort-packages": true diff --git a/psalm-baseline.xml b/psalm-baseline.xml deleted file mode 100644 index 8dbbbbe277..0000000000 --- a/psalm-baseline.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - $parsedDsn['host'] - $parsedDsn['path'] - $parsedDsn['scheme'] - $parsedDsn['user'] - - - - - $userIntegration - $userIntegrations - - - - - $record['channel'] - $record['level'] - $record['level'] - $record['message'] - - - getTimestamp - - - Level|int - int|string|Level|LogLevel::* - - - - - CompatibilityLogLevelTrait - - - Level - - - - - CompatibilityProcessingHandlerTrait - - - Level - - - - - $record['channel'] - $record['level'] - $record['message'] - - - $record['context']['exception'] - - - $record['context'] - $record['context'] - - - - - $record['level'] - $record['level'] - $record['message'] - - - $record['context']['exception'] - - - $record['context'] - $record['context'] - - - - - - SentryProfile|null - - - - - (string) $value - - - - - $value - - - representationSerialize - - - - - $transaction - - - diff --git a/psalm.xml.dist b/psalm.xml.dist deleted file mode 100644 index ea60216a6e..0000000000 --- a/psalm.xml.dist +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Breadcrumb.php b/src/Breadcrumb.php index a5e3116dcd..c6d1fbf445 100644 --- a/src/Breadcrumb.php +++ b/src/Breadcrumb.php @@ -323,7 +323,7 @@ public function withTimestamp(float $timestamp): self * * @param array $data Data used to populate the breadcrumb * - * @psalm-param array{ + * @phpstan-param array{ * level: string, * type?: string, * category: string, diff --git a/src/Client.php b/src/Client.php index 23f546024a..d5b412bebc 100644 --- a/src/Client.php +++ b/src/Client.php @@ -58,7 +58,7 @@ class Client implements ClientInterface /** * @var array The stack of integrations * - * @psalm-var array, IntegrationInterface> + * @phpstan-var array, IntegrationInterface> */ private $integrations; @@ -224,11 +224,11 @@ public function captureLastError(?Scope $scope = null, ?EventHint $hint = null): /** * {@inheritdoc} * - * @psalm-template T of IntegrationInterface + * @phpstan-template T of IntegrationInterface */ public function getIntegration(string $className): ?IntegrationInterface { - /** @psalm-var T|null */ + /** @phpstan-var T|null */ return $this->integrations[$className] ?? null; } diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 0aab383a4d..5d511519f7 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -61,11 +61,11 @@ public function captureEvent(Event $event, ?EventHint $hint = null, ?Scope $scop * * @param string $className The FQCN of the integration * - * @psalm-template T of IntegrationInterface + * @phpstan-template T of IntegrationInterface * - * @psalm-param class-string $className + * @phpstan-param class-string $className * - * @psalm-return T|null + * @phpstan-return T|null */ public function getIntegration(string $className): ?IntegrationInterface; diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index ba44df0c68..0839ce32b4 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -13,7 +13,7 @@ * error handler more than once is not supported and will lead to nasty * problems. The code is based on the Symfony ErrorHandler component. * - * @psalm-import-type StacktraceFrame from FrameBuilder + * @phpstan-import-type StacktraceFrame from FrameBuilder */ final class ErrorHandler { @@ -44,21 +44,21 @@ final class ErrorHandler /** * @var callable[] List of listeners that will act on each captured error * - * @psalm-var (callable(\ErrorException): void)[] + * @phpstan-var (callable(\ErrorException): void)[] */ private $errorListeners = []; /** * @var callable[] List of listeners that will act of each captured fatal error * - * @psalm-var (callable(FatalErrorException): void)[] + * @phpstan-var (callable(FatalErrorException): void)[] */ private $fatalErrorListeners = []; /** * @var callable[] List of listeners that will act on each captured exception * - * @psalm-var (callable(\Throwable): void)[] + * @phpstan-var (callable(\Throwable): void)[] */ private $exceptionListeners = []; @@ -76,7 +76,7 @@ final class ErrorHandler /** * @var callable|null The previous exception handler, if any * - * @psalm-var null|callable(\Throwable): void + * @phpstan-var null|callable(\Throwable): void */ private $previousExceptionHandler; @@ -250,7 +250,7 @@ public static function registerOnceExceptionHandler(): self * and that must accept a single argument * of type \ErrorException * - * @psalm-param callable(\ErrorException): void $listener + * @phpstan-param callable(\ErrorException): void $listener */ public function addErrorHandlerListener(callable $listener): void { @@ -265,7 +265,7 @@ public function addErrorHandlerListener(callable $listener): void * and that must accept a single argument * of type \Sentry\Exception\FatalErrorException * - * @psalm-param callable(FatalErrorException): void $listener + * @phpstan-param callable(FatalErrorException): void $listener */ public function addFatalErrorHandlerListener(callable $listener): void { @@ -280,7 +280,7 @@ public function addFatalErrorHandlerListener(callable $listener): void * and that must accept a single argument * of type \Throwable * - * @psalm-param callable(\Throwable): void $listener + * @phpstan-param callable(\Throwable): void $listener */ public function addExceptionHandlerListener(callable $listener): void { @@ -460,7 +460,7 @@ private function handleException(\Throwable $exception): void * @param string $file The filename the backtrace was raised in * @param int $line The line number the backtrace was raised at * - * @psalm-param list $backtrace + * @phpstan-param list $backtrace * * @return array */ diff --git a/src/Event.php b/src/Event.php index e236c82a79..822571a18a 100644 --- a/src/Event.php +++ b/src/Event.php @@ -908,13 +908,13 @@ public function setSdkMetadata(string $name, $data): self /** * Gets the SDK metadata. * - * @psalm-template T of string|null + * @phpstan-template T of string|null * - * @psalm-param T $name + * @phpstan-param T $name * * @return mixed * - * @psalm-return (T is string ? mixed : array|null) + * @phpstan-return (T is string ? mixed : array|null) */ public function getSdkMetadata(?string $name = null) { diff --git a/src/EventHint.php b/src/EventHint.php index 58b6ffc57e..71a9e577d0 100644 --- a/src/EventHint.php +++ b/src/EventHint.php @@ -40,7 +40,7 @@ final class EventHint /** * Create a EventHint instance from an array of values. * - * @psalm-param array{ + * @phpstan-param array{ * exception?: \Throwable|null, * mechanism?: ExceptionMechanism|null, * stacktrace?: Stacktrace|null, diff --git a/src/FrameBuilder.php b/src/FrameBuilder.php index 8c102ef283..99712435e8 100644 --- a/src/FrameBuilder.php +++ b/src/FrameBuilder.php @@ -12,7 +12,7 @@ * * @internal * - * @psalm-type StacktraceFrame array{ + * @phpstan-type StacktraceFrame array{ * function?: string, * line?: int, * file?: string, @@ -54,7 +54,7 @@ public function __construct(Options $options, RepresentationSerializerInterface * @param int $line The line at which the frame originated * @param array $backtraceFrame The raw frame * - * @psalm-param StacktraceFrame $backtraceFrame + * @phpstan-param StacktraceFrame $backtraceFrame */ public function buildFromBacktraceFrame(string $file, int $line, array $backtraceFrame): Frame { @@ -158,7 +158,7 @@ private function isFrameInApp(string $file, ?string $functionName): bool * * @param array $backtraceFrame The frame data * - * @psalm-param StacktraceFrame $backtraceFrame + * @phpstan-param StacktraceFrame $backtraceFrame * * @return array */ diff --git a/src/Integration/FrameContextifierIntegration.php b/src/Integration/FrameContextifierIntegration.php index 8a541d13df..f0ff6f59b3 100644 --- a/src/Integration/FrameContextifierIntegration.php +++ b/src/Integration/FrameContextifierIntegration.php @@ -114,7 +114,7 @@ private function addContextToStacktraceFrame(int $maxContextLines, Frame $frame) * * @return array * - * @psalm-return array{ + * @phpstan-return array{ * pre_context: string[], * context_line: string|null, * post_context: string[] diff --git a/src/Integration/ModulesIntegration.php b/src/Integration/ModulesIntegration.php index 860d90151b..8148b37782 100644 --- a/src/Integration/ModulesIntegration.php +++ b/src/Integration/ModulesIntegration.php @@ -69,7 +69,10 @@ private static function getInstalledPackages(): array if (class_exists(Versions::class)) { // BC layer for Composer 1, using a transient dependency - return array_keys(Versions::VERSIONS); + /** @var string[] $packages */ + $packages = array_keys(Versions::VERSIONS); + + return $packages; } // this should not happen diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index 72e17ac77e..de33a3f076 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -69,7 +69,7 @@ final class RequestIntegration implements IntegrationInterface /** * @var array The options * - * @psalm-var array{ + * @phpstan-var array{ * pii_sanitize_headers: string[] * } */ @@ -81,7 +81,7 @@ final class RequestIntegration implements IntegrationInterface * @param RequestFetcherInterface|null $requestFetcher PSR-7 request fetcher * @param array $options The options * - * @psalm-param array{ + * @phpstan-param array{ * pii_sanitize_headers?: string[] * } $options */ diff --git a/src/Monolog/BreadcrumbHandler.php b/src/Monolog/BreadcrumbHandler.php index fbda73c26d..29e166671b 100644 --- a/src/Monolog/BreadcrumbHandler.php +++ b/src/Monolog/BreadcrumbHandler.php @@ -42,8 +42,6 @@ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bub } /** - * @psalm-suppress MoreSpecificImplementedParamType - * * @param LogRecord|array{ * level: int, * channel: string, diff --git a/src/Monolog/LogsHandler.php b/src/Monolog/LogsHandler.php index 9eb49e440d..17c67d88d0 100644 --- a/src/Monolog/LogsHandler.php +++ b/src/Monolog/LogsHandler.php @@ -18,8 +18,6 @@ class LogsHandler implements HandlerInterface /** * The minimum logging level at which this handler will be triggered. * - * @psalm-suppress UndefinedDocblockClass - * * @var LogLevel|\Monolog\Level|int */ private $logLevel; @@ -34,8 +32,6 @@ class LogsHandler implements HandlerInterface /** * Creates a new Monolog handler that converts Monolog logs to Sentry logs. * - * @psalm-suppress UndefinedDocblockClass - * * @param LogLevel|\Monolog\Level|int|null $logLevel the minimum logging level at which this handler will be triggered and collects the logs * @param bool $bubble whether the messages that are handled can bubble up the stack or not */ @@ -46,9 +42,6 @@ public function __construct($logLevel = null, bool $bubble = true) } /** - * @psalm-suppress UndefinedDocblockClass - * @psalm-suppress UndefinedClass - * * @param array|LogRecord $record */ public function isHandling($record): bool diff --git a/src/Options.php b/src/Options.php index 5138b64892..fb43d42dcf 100644 --- a/src/Options.php +++ b/src/Options.php @@ -538,7 +538,7 @@ public function setServerName(string $serverName): self * * @return string[] * - * @psalm-return list> + * @phpstan-return list> */ public function getIgnoreExceptions(): array { @@ -587,7 +587,7 @@ public function setIgnoreTransactions(array $ignoreTransaction): self * Gets a callback that will be invoked before an event is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Event, ?EventHint): ?Event + * @phpstan-return callable(Event, ?EventHint): ?Event */ public function getBeforeSendCallback(): callable { @@ -600,7 +600,7 @@ public function getBeforeSendCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Event, ?EventHint): ?Event $callback + * @phpstan-param callable(Event, ?EventHint): ?Event $callback */ public function setBeforeSendCallback(callable $callback): self { @@ -615,7 +615,7 @@ public function setBeforeSendCallback(callable $callback): self * Gets a callback that will be invoked before an transaction is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Event, ?EventHint): ?Event + * @phpstan-return callable(Event, ?EventHint): ?Event */ public function getBeforeSendTransactionCallback(): callable { @@ -628,7 +628,7 @@ public function getBeforeSendTransactionCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Event, ?EventHint): ?Event $callback + * @phpstan-param callable(Event, ?EventHint): ?Event $callback */ public function setBeforeSendTransactionCallback(callable $callback): self { @@ -643,7 +643,7 @@ public function setBeforeSendTransactionCallback(callable $callback): self * Gets a callback that will be invoked before a check-in is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Event, ?EventHint): ?Event + * @phpstan-return callable(Event, ?EventHint): ?Event */ public function getBeforeSendCheckInCallback(): callable { @@ -656,7 +656,7 @@ public function getBeforeSendCheckInCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Event, ?EventHint): ?Event $callback + * @phpstan-param callable(Event, ?EventHint): ?Event $callback */ public function setBeforeSendCheckInCallback(callable $callback): self { @@ -671,7 +671,7 @@ public function setBeforeSendCheckInCallback(callable $callback): self * Gets a callback that will be invoked before an log is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Log): ?Log + * @phpstan-return callable(Log): ?Log */ public function getBeforeSendLogCallback(): callable { @@ -684,7 +684,7 @@ public function getBeforeSendLogCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Log): ?Log $callback + * @phpstan-param callable(Log): ?Log $callback */ public function setBeforeSendLogCallback(callable $callback): self { @@ -699,7 +699,7 @@ public function setBeforeSendLogCallback(callable $callback): self * Gets a callback that will be invoked before metrics are sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Event, ?EventHint): ?Event + * @phpstan-return callable(Event, ?EventHint): ?Event * * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ @@ -743,7 +743,7 @@ public function setBeforeSendMetricCallback(callable $callback): self * * @param callable $callback The callable * - * @psalm-param callable(Event, ?EventHint): ?Event $callback + * @phpstan-param callable(Event, ?EventHint): ?Event $callback * * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. */ @@ -896,7 +896,7 @@ public function setMaxBreadcrumbs(int $maxBreadcrumbs): self /** * Gets a callback that will be invoked when adding a breadcrumb. * - * @psalm-return callable(Breadcrumb): ?Breadcrumb + * @phpstan-return callable(Breadcrumb): ?Breadcrumb */ public function getBeforeBreadcrumbCallback(): callable { @@ -912,7 +912,7 @@ public function getBeforeBreadcrumbCallback(): callable * * @param callable $callback The callback * - * @psalm-param callable(Breadcrumb): ?Breadcrumb $callback + * @phpstan-param callable(Breadcrumb): ?Breadcrumb $callback */ public function setBeforeBreadcrumbCallback(callable $callback): self { @@ -1296,7 +1296,7 @@ public function setClassSerializers(array $serializers): self /** * Gets a callback that will be invoked when we sample a Transaction. * - * @psalm-return null|callable(Tracing\SamplingContext): float + * @phpstan-return null|callable(Tracing\SamplingContext): float */ public function getTracesSampler(): ?callable { @@ -1309,7 +1309,7 @@ public function getTracesSampler(): ?callable * * @param ?callable $sampler The sampler * - * @psalm-param null|callable(Tracing\SamplingContext): float $sampler + * @phpstan-param null|callable(Tracing\SamplingContext): float $sampler */ public function setTracesSampler(?callable $sampler): self { diff --git a/src/SentrySdk.php b/src/SentrySdk.php index 75b2327083..c7cdb36af8 100644 --- a/src/SentrySdk.php +++ b/src/SentrySdk.php @@ -94,13 +94,13 @@ public static function endContext(?int $timeout = null): void * * @param callable $callback The callback to execute * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(): T $callback + * @phpstan-param callable(): T $callback * * @return mixed * - * @psalm-return T + * @phpstan-return T */ public static function withContext(callable $callback, ?int $timeout = null) { diff --git a/src/Serializer/EnvelopItems/EventItem.php b/src/Serializer/EnvelopItems/EventItem.php index 49d58cabce..06fc00a3f3 100644 --- a/src/Serializer/EnvelopItems/EventItem.php +++ b/src/Serializer/EnvelopItems/EventItem.php @@ -148,7 +148,7 @@ public static function toEnvelopeItem(Event $event): string /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * type: string, * value: string, * stacktrace?: array{ diff --git a/src/Serializer/EnvelopItems/TransactionItem.php b/src/Serializer/EnvelopItems/TransactionItem.php index 9eb8eab226..28baf99ea0 100644 --- a/src/Serializer/EnvelopItems/TransactionItem.php +++ b/src/Serializer/EnvelopItems/TransactionItem.php @@ -134,7 +134,7 @@ public static function toEnvelopeItem(Event $event): string /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * span_id: string, * trace_id: string, * parent_span_id?: string, @@ -143,9 +143,9 @@ public static function toEnvelopeItem(Event $event): string * status?: string, * description?: string, * op?: string, + * origin: string, * data?: array, * tags?: array - * _metrics_summary?: array * } */ protected static function serializeSpan(Span $span): array diff --git a/src/Serializer/Traits/BreadcrumbSeralizerTrait.php b/src/Serializer/Traits/BreadcrumbSeralizerTrait.php index 23e840380a..19dc3503df 100644 --- a/src/Serializer/Traits/BreadcrumbSeralizerTrait.php +++ b/src/Serializer/Traits/BreadcrumbSeralizerTrait.php @@ -14,7 +14,7 @@ trait BreadcrumbSeralizerTrait /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * type: string, * category: string, * level: string, diff --git a/src/Serializer/Traits/StacktraceFrameSeralizerTrait.php b/src/Serializer/Traits/StacktraceFrameSeralizerTrait.php index 32e777f7bd..97207e8415 100644 --- a/src/Serializer/Traits/StacktraceFrameSeralizerTrait.php +++ b/src/Serializer/Traits/StacktraceFrameSeralizerTrait.php @@ -14,7 +14,7 @@ trait StacktraceFrameSeralizerTrait /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * filename: string, * lineno: int, * in_app: bool, diff --git a/src/StacktraceBuilder.php b/src/StacktraceBuilder.php index 6360c55601..41d97730e9 100644 --- a/src/StacktraceBuilder.php +++ b/src/StacktraceBuilder.php @@ -11,7 +11,7 @@ * This class builds {@see Stacktrace} objects from an instance of an exception * or from a backtrace. * - * @psalm-import-type StacktraceFrame from FrameBuilder + * @phpstan-import-type StacktraceFrame from FrameBuilder */ final class StacktraceBuilder { @@ -52,7 +52,7 @@ public function buildFromException(\Throwable $exception): Stacktrace * @param string $file The file where the backtrace originated from * @param int $line The line from which the backtrace originated from * - * @psalm-param list $backtrace + * @phpstan-param list $backtrace */ public function buildFromBacktrace(array $backtrace, string $file, int $line): Stacktrace { diff --git a/src/State/HubInterface.php b/src/State/HubInterface.php index 227a8451ed..af5704a65d 100644 --- a/src/State/HubInterface.php +++ b/src/State/HubInterface.php @@ -51,13 +51,13 @@ public function popScope(): bool; * * @param callable $callback The callback to be executed * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(Scope): T $callback + * @phpstan-param callable(Scope): T $callback * * @return mixed|void The callback's return value, upon successful execution * - * @psalm-return T + * @phpstan-return T */ public function withScope(callable $callback); @@ -111,11 +111,11 @@ public function captureCheckIn(string $slug, CheckInStatus $status, $duration = * * @param string $className The FQCN of the integration * - * @psalm-template T of IntegrationInterface + * @phpstan-template T of IntegrationInterface * - * @psalm-param class-string $className + * @phpstan-param class-string $className * - * @psalm-return T|null + * @phpstan-return T|null */ public function getIntegration(string $className): ?IntegrationInterface; diff --git a/src/State/Scope.php b/src/State/Scope.php index 7d3736c421..6edaddf147 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -78,7 +78,7 @@ class Scope /** * @var callable[] List of event processors * - * @psalm-var array + * @phpstan-var array */ private $eventProcessors = []; @@ -90,7 +90,7 @@ class Scope /** * @var callable[] List of event processors * - * @psalm-var array + * @phpstan-var array */ private static $globalEventProcessors = []; diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index ee2ce0f0f6..b4268c0f7e 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -79,7 +79,6 @@ public static function trace(?HubInterface $hub = null): \Closure $response = null; - /** @psalm-suppress UndefinedClass */ if ($responseOrException instanceof ResponseInterface) { $response = $responseOrException; } elseif ($responseOrException instanceof GuzzleRequestException) { diff --git a/src/Tracing/Span.php b/src/Tracing/Span.php index f450506204..346b53b79c 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -426,7 +426,7 @@ public function setData(array $data) * * @return array * - * @psalm-return array{ + * @phpstan-return array{ * data?: array, * description?: string, * op?: string, diff --git a/src/functions.php b/src/functions.php index f146aa44c8..913778dafd 100644 --- a/src/functions.php +++ b/src/functions.php @@ -204,13 +204,13 @@ function configureScope(callable $callback): void * * @param callable $callback The callback to be executed * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(Scope): T $callback + * @phpstan-param callable(Scope): T $callback * * @return mixed|void The callback's return value, upon successful execution * - * @psalm-return T + * @phpstan-return T */ function withScope(callable $callback) { @@ -235,13 +235,13 @@ function endContext(?int $timeout = null): void * @param callable $callback The callback to execute * @param int|null $timeout The maximum number of seconds to wait while flushing the client transport * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(): T $callback + * @phpstan-param callable(): T $callback * * @return mixed * - * @psalm-return T + * @phpstan-return T */ function withContext(callable $callback, ?int $timeout = null) { From b423e88b2603f5296f2413564ee37d879464db62 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Mar 2026 12:08:56 +0100 Subject: [PATCH 23/89] feat(logs): add `log_flush_threshold` flag to trigger auto flush (#2032) --- src/Logs/LogsAggregator.php | 6 ++++ src/Options.php | 39 +++++++++++++++++++++++ src/functions.php | 1 + tests/Logs/LogsAggregatorTest.php | 52 +++++++++++++++++++++++++++++++ tests/OptionsTest.php | 41 ++++++++++++++++++++++++ 5 files changed, 139 insertions(+) diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 16a61adb76..a94b8c3529 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -152,6 +152,12 @@ public function add( } $this->logs[] = $log; + + $logFlushThreshold = $options->getLogFlushThreshold(); + + if ($logFlushThreshold !== null && \count($this->logs) >= $logFlushThreshold) { + $this->flush($hub); + } } public function flush(?HubInterface $hub = null): ?EventId diff --git a/src/Options.php b/src/Options.php index fb43d42dcf..853d479194 100644 --- a/src/Options.php +++ b/src/Options.php @@ -184,6 +184,32 @@ public function getEnableLogs(): bool return $this->options['enable_logs'] ?? false; } + /** + * Gets the number of buffered logs that trigger an immediate flush. + */ + public function getLogFlushThreshold(): ?int + { + /** + * @var int|null $logFlushThreshold + */ + $logFlushThreshold = $this->options['log_flush_threshold']; + + return $logFlushThreshold; + } + + /** + * Sets the number of buffered logs that trigger an immediate flush. + * null will never trigger an immediate flush. + */ + public function setLogFlushThreshold(?int $logFlushThreshold): self + { + $options = array_merge($this->options, ['log_flush_threshold' => $logFlushThreshold]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Sets if metrics should be enabled or not. */ @@ -1337,6 +1363,7 @@ private function configureOptions(OptionsResolver $resolver): void 'sample_rate' => 1, 'enable_tracing' => null, 'enable_logs' => false, + 'log_flush_threshold' => null, 'enable_metrics' => true, 'traces_sample_rate' => null, 'traces_sampler' => null, @@ -1414,6 +1441,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('sample_rate', ['int', 'float']); $resolver->setAllowedTypes('enable_tracing', ['null', 'bool']); $resolver->setAllowedTypes('enable_logs', 'bool'); + $resolver->setAllowedTypes('log_flush_threshold', ['null', 'int']); $resolver->setAllowedTypes('enable_metrics', 'bool'); $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); @@ -1467,6 +1495,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedValues('max_breadcrumbs', \Closure::fromCallable([$this, 'validateMaxBreadcrumbsOptions'])); $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); $resolver->setAllowedValues('context_lines', \Closure::fromCallable([$this, 'validateContextLinesOption'])); + $resolver->setAllowedValues('log_flush_threshold', \Closure::fromCallable([$this, 'validateLogFlushThresholdOption'])); $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); @@ -1632,4 +1661,14 @@ private function validateContextLinesOption(?int $contextLines): bool { return $contextLines === null || $contextLines >= 0; } + + /** + * Validates that the value passed to the "log_flush_threshold" option is valid. + * + * @param int|null $logFlushThreshold The value to validate + */ + private function validateLogFlushThresholdOption(?int $logFlushThreshold): bool + { + return $logFlushThreshold === null || $logFlushThreshold > 0; + } } diff --git a/src/functions.php b/src/functions.php index 913778dafd..0fd90b7921 100644 --- a/src/functions.php +++ b/src/functions.php @@ -49,6 +49,7 @@ * in_app_include?: array, * integrations?: IntegrationInterface[]|callable(IntegrationInterface[]): IntegrationInterface[], * logger?: LoggerInterface|null, + * log_flush_threshold?: int|null, * max_breadcrumbs?: int, * max_request_body_size?: "none"|"never"|"small"|"medium"|"always", * max_value_length?: int, diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index 89e175a70c..f421209cfd 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -12,6 +12,7 @@ use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\State\Scope; +use Sentry\Tests\StubTransport; use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanId; @@ -207,4 +208,55 @@ public function testAttributesAreAddedToLogMessage(): void $this->assertSame('foo@example.com', $attributes->get('user.email')->getValue()); $this->assertSame('my_user', $attributes->get('user.name')->getValue()); } + + public function testFlushesImmediatelyWhenThresholdIsReached(): void + { + StubTransport::$events = []; + + $transport = new StubTransport(); + $client = ClientBuilder::create([ + 'enable_logs' => true, + 'log_flush_threshold' => 2, + ])->setTransport($transport)->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $aggregator = new LogsAggregator(); + + $aggregator->add(LogLevel::info(), 'First message'); + + $this->assertCount(1, $aggregator->all()); + $this->assertCount(0, StubTransport::$events); + + $aggregator->add(LogLevel::warn(), 'Second message'); + + $this->assertCount(0, $aggregator->all()); + $this->assertCount(1, StubTransport::$events); + $this->assertCount(2, StubTransport::$events[0]->getLogs()); + $this->assertSame('First message', StubTransport::$events[0]->getLogs()[0]->getBody()); + $this->assertSame('Second message', StubTransport::$events[0]->getLogs()[1]->getBody()); + } + + public function testDoesNotFlushImmediatelyWhenThresholdIsNull(): void + { + StubTransport::$events = []; + + $transport = new StubTransport(); + $client = ClientBuilder::create([ + 'enable_logs' => true, + 'log_flush_threshold' => null, + ])->setTransport($transport)->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $aggregator = new LogsAggregator(); + + $aggregator->add(LogLevel::info(), 'First message'); + $aggregator->add(LogLevel::warn(), 'Second message'); + + $this->assertCount(2, $aggregator->all()); + $this->assertCount(0, StubTransport::$events); + } } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 5c9e498223..9ae04194b3 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -100,6 +100,20 @@ public static function optionsDataProvider(): \Generator 'setEnableLogs', ]; + yield [ + 'log_flush_threshold', + 10, + 'getLogFlushThreshold', + 'setLogFlushThreshold', + ]; + + yield [ + 'log_flush_threshold', + null, + 'getLogFlushThreshold', + 'setLogFlushThreshold', + ]; + yield [ 'traces_sample_rate', 0.5, @@ -641,6 +655,33 @@ public static function contextLinesOptionValidatesInputValueDataProvider(): \Gen ]; } + /** + * @dataProvider logFlushThresholdOptionIsValidatedCorrectlyDataProvider + */ + public function testLogFlushThresholdOptionIsValidatedCorrectly(bool $isValid, $value): void + { + if (!$isValid) { + $this->expectException(InvalidOptionsException::class); + } + + $options = new Options(['log_flush_threshold' => $value]); + + $this->assertSame($value, $options->getLogFlushThreshold()); + } + + public static function logFlushThresholdOptionIsValidatedCorrectlyDataProvider(): array + { + return [ + [false, -1], + [false, 0], + [true, 1], + [true, 10], + [true, null], + [false, 'string'], + [false, '1'], + ]; + } + /** * @backupGlobals enabled */ From aced06b0d12c3beefea6e9df7cb0d393bbd93081 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Mar 2026 12:54:11 +0100 Subject: [PATCH 24/89] feat(otel): add OTLP integration (#2030) --- .github/workflows/ci.yml | 4 + composer.json | 9 +- src/Dsn.php | 8 + src/Integration/OTLPIntegration.php | 206 ++++++++++++ src/Logs/LogsAggregator.php | 41 ++- src/Metrics/MetricsAggregator.php | 35 +- src/State/Scope.php | 105 +++++- src/Tracing/GuzzleTracingMiddleware.php | 12 +- src/Tracing/PropagationContext.php | 2 +- src/Util/Http.php | 15 +- src/functions.php | 34 ++ tests/DsnTest.php | 38 +++ .../OpenTelemetry/StubOtelHttpClient.php | 30 ++ .../OpenTelemetry/TestClientDiscoverer.php | 21 ++ .../OpenTelemetry/TestDiscoveryStrategy.php | 29 ++ tests/FunctionsTest.php | 60 ++++ tests/Integration/OTLPIntegrationTest.php | 300 ++++++++++++++++++ tests/Logs/LogsAggregatorTest.php | 54 ++++ tests/Metrics/TraceMetricsTest.php | 21 ++ tests/SentrySdkExtension.php | 11 + tests/State/ScopeTest.php | 81 +++++ tests/Tracing/GuzzleTracingMiddlewareTest.php | 44 ++- tests/Util/HttpTest.php | 16 +- 23 files changed, 1130 insertions(+), 46 deletions(-) create mode 100644 src/Integration/OTLPIntegration.php create mode 100644 tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php create mode 100644 tests/Fixtures/OpenTelemetry/TestClientDiscoverer.php create mode 100644 tests/Fixtures/OpenTelemetry/TestDiscoveryStrategy.php create mode 100644 tests/Integration/OTLPIntegrationTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb07996426..605843cbf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,10 @@ jobs: if: ${{ matrix.os == 'windows-latest' || matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} run: composer remove spiral/roadrunner-http spiral/roadrunner-worker --dev --no-interaction --no-update + - name: Remove OpenTelemetry dependencies on unsupported PHP versions + if: ${{ matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} + run: composer remove open-telemetry/api open-telemetry/exporter-otlp open-telemetry/sdk --dev --no-interaction --no-update + - name: Set phpunit/phpunit version constraint run: composer require phpunit/phpunit:'${{ matrix.php.phpunit }}' --dev --no-interaction --no-update diff --git a/composer.json b/composer.json index ba94d3f138..0bfd60f6c3 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,9 @@ "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.0", + "open-telemetry/exporter-otlp": "^1.0", + "open-telemetry/sdk": "^1.0", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", "phpunit/phpunit": "^8.5.52|^9.6.34", @@ -74,7 +77,11 @@ "phpstan": "vendor/bin/phpstan analyse" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": false, + "tbachert/spi": false + } }, "prefer-stable": true } diff --git a/src/Dsn.php b/src/Dsn.php index b6f9522970..d89d6804bf 100644 --- a/src/Dsn.php +++ b/src/Dsn.php @@ -192,6 +192,14 @@ public function getCspReportEndpointUrl(): string return $this->getBaseEndpointUrl() . '/security/?sentry_key=' . $this->publicKey; } + /** + * Returns the URL of the API for the OTLP traces endpoint. + */ + public function getOtlpTracesEndpointUrl(): string + { + return $this->getBaseEndpointUrl() . '/integration/otlp/v1/traces/'; + } + /** * @see https://www.php.net/manual/en/language.oop5.magic.php#object.tostring */ diff --git a/src/Integration/OTLPIntegration.php b/src/Integration/OTLPIntegration.php new file mode 100644 index 0000000000..9f60501706 --- /dev/null +++ b/src/Integration/OTLPIntegration.php @@ -0,0 +1,206 @@ +setupOtlpTracesExporter = $setupOtlpTracesExporter; + $this->collectorUrl = $collectorUrl; + } + + public function setOptions(Options $options): void + { + $this->options = $options; + } + + public function setupOnce(): void + { + $options = $this->options; + + if ($options === null) { + $this->logDebug('Skipping OTLPIntegration setup because client options were not provided.'); + + return; + } + + if ($options->isTracingEnabled()) { + $this->logDebug('Skipping OTLPIntegration because Sentry tracing is enabled. Disable "traces_sample_rate", "traces_sampler", and "enable_tracing" before using OTLPIntegration.'); + + return; + } + + Scope::registerExternalPropagationContext(static function (): ?array { + $currentHub = SentrySdk::getCurrentHub(); + $integration = $currentHub->getIntegration(self::class); + + if (!$integration instanceof self) { + return null; + } + + return $integration->getCurrentOpenTelemetryPropagationContext(); + }); + + if ($this->setupOtlpTracesExporter) { + $this->configureOtlpTracesExporter($options); + } + } + + public function getCollectorUrl(): ?string + { + return $this->collectorUrl; + } + + /** + * @return array{trace_id: string, span_id: string}|null + */ + private function getCurrentOpenTelemetryPropagationContext(): ?array + { + if (!class_exists(\OpenTelemetry\API\Trace\Span::class)) { + return null; + } + + $spanContext = \OpenTelemetry\API\Trace\Span::getCurrent()->getContext(); + + if (!$spanContext->isValid()) { + return null; + } + + return [ + 'trace_id' => $spanContext->getTraceId(), + 'span_id' => $spanContext->getSpanId(), + ]; + } + + private function configureOtlpTracesExporter(Options $options): void + { + $endpoint = $this->collectorUrl; + $headers = []; + $dsn = $options->getDsn(); + + if ($endpoint === null && $dsn !== null) { + $endpoint = $dsn->getOtlpTracesEndpointUrl(); + $headers['X-Sentry-Auth'] = Http::getSentryAuthHeader($dsn, Client::SDK_IDENTIFIER, Client::SDK_VERSION); + } + + if ($endpoint === null) { + $this->logDebug('Skipping automatic OTLP exporter setup because neither a DSN nor a collector URL is configured.'); + + return; + } + + if (!$this->shouldConfigureOtlpTracesExporter()) { + return; + } + + try { + $transport = (new \OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory())->create( + $endpoint, + \OpenTelemetry\Contrib\Otlp\ContentTypes::PROTOBUF, + $headers + ); + $spanExporter = new \OpenTelemetry\Contrib\Otlp\SpanExporter($transport); + $batchSpanProcessor = new \OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor( + $spanExporter, + \OpenTelemetry\API\Common\Time\Clock::getDefault() + ); + + (new \OpenTelemetry\SDK\SdkBuilder()) + ->setTracerProvider(new \OpenTelemetry\SDK\Trace\TracerProvider($batchSpanProcessor)) + ->buildAndRegisterGlobal(); + } catch (\Throwable $exception) { + $this->logDebug(\sprintf('Skipping automatic OTLP exporter setup because it could not be configured: %s', $exception->getMessage())); + } + } + + private function shouldConfigureOtlpTracesExporter(): bool + { + if (\PHP_VERSION_ID < 80100) { + $this->logDebug('Skipping automatic OTLP exporter setup because it requires PHP 8.1 or newer.'); + + return false; + } + + foreach ([ + \OpenTelemetry\API\Globals::class, + \OpenTelemetry\API\Common\Time\Clock::class, + \OpenTelemetry\SDK\SdkBuilder::class, + \OpenTelemetry\SDK\Trace\TracerProvider::class, + \OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor::class, + \OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory::class, + \OpenTelemetry\Contrib\Otlp\SpanExporter::class, + ] as $className) { + if (!class_exists($className)) { + $this->logDebug('Skipping automatic OTLP exporter setup because the required OpenTelemetry SDK/exporter classes are not available.'); + + return false; + } + } + + try { + if (!$this->isNoopTracerProvider(\OpenTelemetry\API\Globals::tracerProvider())) { + $this->logDebug('Skipping automatic OTLP exporter setup because the existing OpenTelemetry tracer provider cannot be modified after construction.'); + + return false; + } + } catch (\Throwable $exception) { + $this->logDebug(\sprintf('Skipping automatic OTLP exporter setup because the current OpenTelemetry tracer provider could not be inspected: %s', $exception->getMessage())); + + return false; + } + + return true; + } + + private function isNoopTracerProvider(?object $tracerProvider): bool + { + return $tracerProvider === null || $tracerProvider instanceof \OpenTelemetry\API\Trace\NoopTracerProvider; + } + + private function logDebug(string $message): void + { + $this->getLogger()->debug($message); + } + + private function getLogger(): LoggerInterface + { + if ($this->options !== null) { + return $this->options->getLoggerOrNullLogger(); + } + + $currentHub = SentrySdk::getCurrentHub(); + $client = $currentHub->getClient(); + + if ($client !== null) { + return $client->getOptions()->getLoggerOrNullLogger(); + } + + return new NullLogger(); + } +} diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index a94b8c3529..6e7136e06b 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -72,11 +72,15 @@ public function add( $formattedMessage = $message; } - $log = (new Log($timestamp, $this->getTraceId($hub), $level, $formattedMessage)) + $traceData = $this->getTraceData($hub); + $traceId = $traceData['trace_id']; + $parentSpanId = $traceData['parent_span_id']; + + $log = (new Log($timestamp, $traceId, $level, $formattedMessage)) ->setAttribute('sentry.release', $options->getRelease()) ->setAttribute('sentry.environment', $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT) ->setAttribute('sentry.server.address', $options->getServerName()) - ->setAttribute('sentry.trace.parent_span_id', $hub->getSpan() ? $hub->getSpan()->getSpanId() : null); + ->setAttribute('sentry.trace.parent_span_id', $parentSpanId); if ($client instanceof Client) { $log->setAttribute('sentry.sdk.name', $client->getSdkIdentifier()); @@ -182,20 +186,41 @@ public function all(): array return $this->logs; } - private function getTraceId(HubInterface $hub): string + /** + * @return array{trace_id: string, parent_span_id: string|null} + */ + private function getTraceData(HubInterface $hub): array { $span = $hub->getSpan(); if ($span !== null) { - return (string) $span->getTraceId(); + return [ + 'trace_id' => (string) $span->getTraceId(), + 'parent_span_id' => (string) $span->getSpanId(), + ]; } - $traceId = ''; + $traceData = null; + + $hub->configureScope(static function (Scope $scope) use (&$traceData): void { + $externalPropagationContext = Scope::getExternalPropagationContext(); + + if ($externalPropagationContext !== null) { + $traceData = [ + 'trace_id' => $externalPropagationContext['trace_id'], + 'parent_span_id' => $externalPropagationContext['span_id'], + ]; + + return; + } - $hub->configureScope(static function (Scope $scope) use (&$traceId) { - $traceId = (string) $scope->getPropagationContext()->getTraceId(); + $traceData = [ + 'trace_id' => (string) $scope->getPropagationContext()->getTraceId(), + 'parent_span_id' => null, + ]; }); - return $traceId; + /** @var array{trace_id: string, parent_span_id: string|null} $traceData */ + return $traceData; } } diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index 46f015be0e..47bfcb64d0 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -14,6 +14,8 @@ use Sentry\SentrySdk; use Sentry\State\HubInterface; use Sentry\State\Scope; +use Sentry\Tracing\SpanId; +use Sentry\Tracing\TraceId; use Sentry\Unit; use Sentry\Util\RingBuffer; @@ -104,24 +106,12 @@ public function add( $attributes += $defaultAttributes; } - $spanId = null; - $traceId = null; - - $span = $hub->getSpan(); - if ($span !== null) { - $spanId = $span->getSpanId(); - $traceId = $span->getTraceId(); - } else { - $hub->configureScope(static function (Scope $scope) use (&$traceId, &$spanId) { - $propagationContext = $scope->getPropagationContext(); - $traceId = $propagationContext->getTraceId(); - $spanId = $propagationContext->getSpanId(); - }); - } + $traceContext = $this->getTraceContext($hub); + $traceId = new TraceId($traceContext['trace_id']); + $spanId = new SpanId($traceContext['span_id']); $metricTypeClass = self::METRIC_TYPES[$type]; /** @var Metric $metric */ - /** @phpstan-ignore-next-line */ $metric = new $metricTypeClass($name, $value, $traceId, $spanId, $attributes, microtime(true), $unit); if ($client !== null) { @@ -146,4 +136,19 @@ public function flush(?HubInterface $hub = null): ?EventId return $hub->captureEvent($event); } + + /** + * @return array{trace_id: string, span_id: string} + */ + private function getTraceContext(HubInterface $hub): array + { + $traceContext = null; + + $hub->configureScope(static function (Scope $scope) use (&$traceContext): void { + $traceContext = $scope->getTraceContext(); + }); + + /** @var array{trace_id: string, span_id: string} $traceContext */ + return $traceContext; + } } diff --git a/src/State/Scope.php b/src/State/Scope.php index 6edaddf147..600e5857b5 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -94,6 +94,11 @@ class Scope */ private static $globalEventProcessors = []; + /** + * @var callable|null + */ + private static $externalPropagationContextCallback; + public function __construct(?PropagationContext $propagationContext = null) { $this->propagationContext = $propagationContext ?? PropagationContext::fromDefaults(); @@ -359,6 +364,53 @@ public static function addGlobalEventProcessor(callable $eventProcessor): void self::$globalEventProcessors[] = $eventProcessor; } + public static function registerExternalPropagationContext(callable $callback): void + { + self::$externalPropagationContextCallback = $callback; + } + + public static function clearExternalPropagationContext(): void + { + self::$externalPropagationContextCallback = null; + } + + /** + * @return array{trace_id: string, span_id: string}|null + */ + public static function getExternalPropagationContext(): ?array + { + $callback = self::$externalPropagationContextCallback; + if (!\is_callable($callback)) { + return null; + } + + try { + $context = $callback(); + } catch (\Throwable $exception) { + return null; + } + + if (!\is_array($context)) { + return null; + } + + $traceId = $context['trace_id'] ?? null; + $spanId = $context['span_id'] ?? null; + + if (!\is_string($traceId) || preg_match('/^[0-9a-f]{32}$/i', $traceId) !== 1) { + return null; + } + + if (!\is_string($spanId) || preg_match('/^[0-9a-f]{16}$/i', $spanId) !== 1) { + return null; + } + + return [ + 'trace_id' => $traceId, + 'span_id' => $spanId, + ]; + } + /** * Clears the scope and resets any data it contains. * @@ -430,24 +482,30 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op /** * Apply the trace context to errors if there is a Span on the Scope. - * Else fallback to the propagation context. + * Else fallback to the external propagation context or to the + * propagation context. * But do not override a trace context already present. */ - if ($this->span !== null) { - if (!\array_key_exists('trace', $event->getContexts())) { - $event->setContext('trace', $this->span->getTraceContext()); - } + $externalPropagationContext = null; + if ($this->span === null) { + $externalPropagationContext = self::getExternalPropagationContext(); + } + + $traceContext = $this->span !== null + ? $this->span->getTraceContext() + : ($externalPropagationContext ?? $this->propagationContext->getTraceContext()); + if (!\array_key_exists('trace', $event->getContexts())) { + $event->setContext('trace', $traceContext); + } + + if ($this->span !== null) { // Apply the dynamic sampling context to errors if there is a Transaction on the Scope $transaction = $this->span->getTransaction(); if ($transaction !== null) { $event->setSdkMetadata('dynamic_sampling_context', $transaction->getDynamicSamplingContext()); } - } else { - if (!\array_key_exists('trace', $event->getContexts())) { - $event->setContext('trace', $this->propagationContext->getTraceContext()); - } - + } elseif ($externalPropagationContext === null) { $dynamicSamplingContext = $this->propagationContext->getDynamicSamplingContext(); if ($dynamicSamplingContext === null && $options !== null) { $dynamicSamplingContext = DynamicSamplingContext::fromOptions($options, $this); @@ -513,6 +571,33 @@ public function getTransaction(): ?Transaction return null; } + public function hasExternalPropagationContext(): bool + { + return $this->span === null && self::getExternalPropagationContext() !== null; + } + + /** + * @return array{ + * trace_id: string, + * span_id: string, + * parent_span_id?: string, + * data?: array, + * description?: string, + * op?: string, + * status?: string, + * tags?: array, + * origin?: string + * } + */ + public function getTraceContext(): array + { + if ($this->span !== null) { + return $this->span->getTraceContext(); + } + + return self::getExternalPropagationContext() ?? $this->propagationContext->getTraceContext(); + } + public function getPropagationContext(): PropagationContext { return $this->propagationContext; diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index b4268c0f7e..8e277e4c07 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -63,9 +63,15 @@ public static function trace(?HubInterface $hub = null): \Closure } if (self::shouldAttachTracingHeaders($client, $request)) { - $request = $request - ->withHeader('sentry-trace', getTraceparent()) - ->withHeader('baggage', getBaggage()); + $traceParent = getTraceparent(); + if ($traceParent !== '') { + $request = $request->withHeader('sentry-trace', $traceParent); + } + + $baggage = getBaggage(); + if ($baggage !== '') { + $request = $request->withHeader('baggage', $baggage); + } } $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $childSpan, $parentSpan, $partialUri) { diff --git a/src/Tracing/PropagationContext.php b/src/Tracing/PropagationContext.php index a4e8551764..1162484e13 100644 --- a/src/Tracing/PropagationContext.php +++ b/src/Tracing/PropagationContext.php @@ -112,7 +112,7 @@ public function toBaggage(): string } /** - * @return array + * @return array{trace_id: string, span_id: string, parent_span_id?: string} */ public function getTraceContext(): array { diff --git a/src/Util/Http.php b/src/Util/Http.php index efe903ad73..ee2490dbb6 100644 --- a/src/Util/Http.php +++ b/src/Util/Http.php @@ -12,10 +12,7 @@ */ final class Http { - /** - * @return string[] - */ - public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): array + public static function getSentryAuthHeader(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): string { $authHeader = [ 'sentry_version=' . Client::PROTOCOL_VERSION, @@ -23,9 +20,17 @@ public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string 'sentry_key=' . $dsn->getPublicKey(), ]; + return 'Sentry ' . implode(', ', $authHeader); + } + + /** + * @return string[] + */ + public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): array + { return [ 'Content-Type: application/x-sentry-envelope', - 'X-Sentry-Auth: Sentry ' . implode(', ', $authHeader), + 'X-Sentry-Auth: ' . self::getSentryAuthHeader($dsn, $sdkIdentifier, $sdkVersion), ]; } diff --git a/src/functions.php b/src/functions.php index 0fd90b7921..f8ae0be4fc 100644 --- a/src/functions.php +++ b/src/functions.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\IntegrationInterface; +use Sentry\Integration\OTLPIntegration; use Sentry\Logs\Logs; use Sentry\Metrics\Metrics; use Sentry\Metrics\TraceMetrics; @@ -309,6 +310,31 @@ function trace(callable $trace, SpanContext $context) }); } +/** + * Returns the OTLP traces endpoint configured for the current client. + */ +function getOtlpTracesEndpointUrl(): ?string +{ + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + if ($client === null) { + return null; + } + + $integration = $hub->getIntegration(OTLPIntegration::class); + if ($integration instanceof OTLPIntegration && $integration->getCollectorUrl() !== null) { + return $integration->getCollectorUrl(); + } + + $dsn = $client->getOptions()->getDsn(); + if ($dsn === null) { + return null; + } + + return $dsn->getOtlpTracesEndpointUrl(); +} + /** * Creates the current Sentry traceparent string, to be used as a HTTP header value * or HTML meta tag value. @@ -333,6 +359,10 @@ function getTraceparent(): string $traceParent = ''; $hub->configureScope(static function (Scope $scope) use (&$traceParent) { + if ($scope->hasExternalPropagationContext()) { + return; + } + $traceParent = $scope->getPropagationContext()->toTraceparent(); }); @@ -376,6 +406,10 @@ function getBaggage(): string $baggage = ''; $hub->configureScope(static function (Scope $scope) use (&$baggage) { + if ($scope->hasExternalPropagationContext()) { + return; + } + $baggage = $scope->getPropagationContext()->toBaggage(); }); diff --git a/tests/DsnTest.php b/tests/DsnTest.php index f8bb169d89..129e15f840 100644 --- a/tests/DsnTest.php +++ b/tests/DsnTest.php @@ -245,6 +245,44 @@ public static function getCspReportEndpointUrlDataProvider(): \Generator ]; } + /** + * @dataProvider getOtlpTracesEndpointUrlDataProvider + */ + public function testGetOtlpTracesEndpointUrl(string $value, string $expectedUrl): void + { + $dsn = Dsn::createFromString($value); + + $this->assertSame($expectedUrl, $dsn->getOtlpTracesEndpointUrl()); + } + + public static function getOtlpTracesEndpointUrlDataProvider(): \Generator + { + yield [ + 'http://public@example.com/sentry/1', + 'http://example.com/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'http://public@example.com/1', + 'http://example.com/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'http://public@example.com:8080/sentry/1', + 'http://example.com:8080/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'https://public@example.com/sentry/1', + 'https://example.com/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'https://public@example.com:4343/sentry/1', + 'https://example.com:4343/sentry/api/1/integration/otlp/v1/traces/', + ]; + } + /** * @dataProvider toStringDataProvider */ diff --git a/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php b/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php new file mode 100644 index 0000000000..2118637e80 --- /dev/null +++ b/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php @@ -0,0 +1,30 @@ + + */ + public static function getCandidates(string $type): array + { + if (is_a(ClientInterface::class, $type, true)) { + return [['class' => StubOtelHttpClient::class, 'condition' => StubOtelHttpClient::class]]; + } + + if (is_a(RequestFactoryInterface::class, $type, true) || is_a(StreamFactoryInterface::class, $type, true)) { + return [['class' => Psr17Factory::class, 'condition' => Psr17Factory::class]]; + } + + return []; + } +} diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index b92926d2b7..7d712f6524 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -12,6 +12,7 @@ use Sentry\Event; use Sentry\EventHint; use Sentry\EventId; +use Sentry\Integration\OTLPIntegration; use Sentry\MonitorConfig; use Sentry\MonitorSchedule; use Sentry\Options; @@ -41,6 +42,7 @@ use function Sentry\continueTrace; use function Sentry\endContext; use function Sentry\getBaggage; +use function Sentry\getOtlpTracesEndpointUrl; use function Sentry\getTraceparent; use function Sentry\init; use function Sentry\startContext; @@ -552,6 +554,27 @@ public function testTraceparentWithTracingEnabled(): void $this->assertSame('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', $traceParent); } + public function testTraceHeadersAreEmptyWhenExternalPropagationContextIsActive(): void + { + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $propagationContext->setSpanId(new SpanId('566e3688a61d4bc8')); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + SentrySdk::setCurrentHub(new Hub(null, new Scope($propagationContext))); + + $this->assertSame('', getTraceparent()); + $this->assertSame('', getBaggage()); + + Scope::clearExternalPropagationContext(); + } + public function testBaggageWithTracingDisabled(): void { $propagationContext = PropagationContext::fromDefaults(); @@ -610,6 +633,43 @@ public function testBaggageWithTracingEnabled(): void $this->assertSame('sentry-trace_id=566e3688a61d4bc888951642d6f14a19,sentry-sample_rate=1,sentry-transaction=Test,sentry-release=1.0.0,sentry-environment=development,sentry-sampled=true,sentry-sample_rand=0.25', $baggage); } + public function testGetOtlpTracesEndpointUrlFallsBackToDsn(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn(null); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame('https://example.com/api/1/integration/otlp/v1/traces/', getOtlpTracesEndpointUrl()); + } + + public function testGetOtlpTracesEndpointUrlPrefersCollectorUrl(): void + { + $integration = new OTLPIntegration(false, 'http://collector:4318/v1/traces'); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn($integration); + $client->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame('http://collector:4318/v1/traces', getOtlpTracesEndpointUrl()); + } + public function testContinueTrace(): void { $hub = new Hub(); diff --git a/tests/Integration/OTLPIntegrationTest.php b/tests/Integration/OTLPIntegrationTest.php new file mode 100644 index 0000000000..a0f3e5680f --- /dev/null +++ b/tests/Integration/OTLPIntegrationTest.php @@ -0,0 +1,300 @@ +discoveryStrategies = iterator_to_array($strategies); + } else { + $this->discoveryStrategies = $strategies; + } + } + + if (class_exists(StubOtelHttpClient::class, false)) { + StubOtelHttpClient::reset(); + } + + StubLogger::$logs = []; + } + + protected function tearDown(): void + { + if (class_exists(Context::class) && class_exists(ContextStorage::class)) { + Context::setStorage(new ContextStorage()); + } + + if ($this->discoveryStrategies !== null && class_exists(ClassDiscovery::class)) { + ClassDiscovery::setStrategies($this->discoveryStrategies); + } + + if (class_exists(HttpClientDiscovery::class) && method_exists(HttpClientDiscovery::class, 'reset')) { + HttpClientDiscovery::reset(); + } + + if (class_exists(StubOtelHttpClient::class, false)) { + StubOtelHttpClient::reset(); + } + + parent::tearDown(); + } + + public function testSetupOnceLogsAndSkipsWhenSentryTracingIsEnabled(): void + { + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'logger' => StubLogger::getInstance(), + 'traces_sample_rate' => 1.0, + ])); + + $integration->setupOnce(); + + $this->assertNull(Scope::getExternalPropagationContext()); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame('debug', StubLogger::$logs[0]['level']); + $this->assertStringContainsString('Skipping OTLPIntegration because Sentry tracing is enabled.', StubLogger::$logs[0]['message']); + } + + public function testSetupOnceRegistersExternalPropagationContext(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'dsn' => null, + ])); + $integration->setupOnce(); + + $otelScope = $this->activateOpenTelemetrySpan(); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn($integration); + + try { + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame([ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], Scope::getExternalPropagationContext()); + } finally { + $otelScope->detach(); + } + } + + public function testExternalPropagationContextIsIgnoredWhenCurrentClientDoesNotHaveIntegration(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'dsn' => null, + ])); + $integration->setupOnce(); + + $otelScope = $this->activateOpenTelemetrySpan(); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn(null); + + try { + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertNull(Scope::getExternalPropagationContext()); + } finally { + $otelScope->detach(); + } + } + + public function testSetupOnceCreatesTracerProviderWhenMissing(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertSame($tracerProvider, Globals::tracerProvider()); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(1, StubOtelHttpClient::$requests); + $this->assertSame('https://example.com/api/1/integration/otlp/v1/traces/', (string) StubOtelHttpClient::$requests[0]->getUri()); + $this->assertStringContainsString('sentry_key=public', StubOtelHttpClient::$requests[0]->getHeaderLine('X-Sentry-Auth')); + } + + public function testSetupOnceLogsAndSkipsWhenExistingTracerProviderCannotBeModified(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $existingTracerProvider = new TracerProvider(); + (new SdkBuilder()) + ->setTracerProvider($existingTracerProvider) + ->buildAndRegisterGlobal(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'logger' => StubLogger::getInstance(), + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + $this->assertSame($existingTracerProvider, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(0, StubOtelHttpClient::$requests); + $this->assertCount(1, StubLogger::$logs); + $this->assertStringContainsString('existing OpenTelemetry tracer provider cannot be modified after construction', StubLogger::$logs[0]['message']); + } + + public function testSetupOnceUsesCollectorUrlWithoutSentryAuthHeader(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $integration = new OTLPIntegration(true, 'http://collector:4318/v1/traces'); + $integration->setOptions(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(1, StubOtelHttpClient::$requests); + $this->assertSame('http://collector:4318/v1/traces', (string) StubOtelHttpClient::$requests[0]->getUri()); + $this->assertSame('', StubOtelHttpClient::$requests[0]->getHeaderLine('X-Sentry-Auth')); + } + + public function testSetupOnceLogsAndSkipsExporterSetupWhenEndpointCannotBeResolved(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'dsn' => null, + 'logger' => StubLogger::getInstance(), + ])); + + $integration->setupOnce(); + + $this->assertNotInstanceOf(TracerProvider::class, Globals::tracerProvider()); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame('debug', StubLogger::$logs[0]['level']); + $this->assertStringContainsString('Skipping automatic OTLP exporter setup because neither a DSN nor a collector URL is configured.', StubLogger::$logs[0]['message']); + } + + private function requireOpenTelemetry(): void + { + if (\PHP_VERSION_ID < 80100) { + $this->markTestSkipped('OpenTelemetry integration tests require PHP 8.1 or newer.'); + } + + foreach ([ + Globals::class, + Span::class, + SpanContext::class, + Context::class, + ContextStorage::class, + HttpClientDiscovery::class, + TracerProvider::class, + SdkBuilder::class, + ClassDiscovery::class, + ] as $className) { + if (!class_exists($className) && !interface_exists($className)) { + $this->markTestSkipped(\sprintf('OpenTelemetry integration tests require the optional package that provides "%s".', $className)); + } + } + } + + private function activateOpenTelemetrySpan() + { + return Span::wrap(SpanContext::create( + '771a43a4192642f0b136d5159a501700', + '1234567890abcdef' + ))->activate(); + } + + private function useCapturingHttpClient(): void + { + $this->requireOpenTelemetry(); + + if (method_exists(HttpClientDiscovery::class, 'setDiscoverers')) { + HttpClientDiscovery::setDiscoverers([new TestClientDiscoverer()]); + } else { + ClassDiscovery::prependStrategy(TestDiscoveryStrategy::class); + } + + StubOtelHttpClient::reset(); + } + + private function exportSpan(TracerProvider $tracerProvider): void + { + $span = $tracerProvider + ->getTracer('sentry.tests.otlp') + ->spanBuilder('otlp-test-span') + ->startSpan(); + + $span->end(); + $tracerProvider->shutdown(); + } +} diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index f421209cfd..8f47ce48da 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -13,6 +13,7 @@ use Sentry\State\Hub; use Sentry\State\Scope; use Sentry\Tests\StubTransport; +use Sentry\Tracing\PropagationContext; use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanId; @@ -259,4 +260,57 @@ public function testDoesNotFlushImmediatelyWhenThresholdIsNull(): void $this->assertCount(2, $aggregator->all()); $this->assertCount(0, StubTransport::$events); } + + public function testDoesNotUsePropagationContextSpanIdAsParentSpanIdWhenNoLocalSpanExists(): void + { + $client = ClientBuilder::create([ + 'enable_logs' => true, + ])->getClient(); + + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('771a43a4192642f0b136d5159a501700')); + $propagationContext->setSpanId(new SpanId('1234567890abcdef')); + + $hub = new Hub($client, new Scope($propagationContext)); + SentrySdk::setCurrentHub($hub); + + $aggregator = new LogsAggregator(); + $aggregator->add(LogLevel::info(), 'Test message'); + + $logs = $aggregator->all(); + $this->assertCount(1, $logs); + $this->assertSame('771a43a4192642f0b136d5159a501700', $logs[0]->getTraceId()); + + $parentSpanId = $logs[0]->attributes()->get('sentry.trace.parent_span_id'); + $this->assertNotNull($parentSpanId); + // Log attributes normalize null values to the string "null". + $this->assertSame('null', $parentSpanId->getValue()); + } + + public function testUsesExternalPropagationContextWhenNoLocalSpanExists(): void + { + $client = ClientBuilder::create([ + 'enable_logs' => true, + ])->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $aggregator = new LogsAggregator(); + $aggregator->add(LogLevel::info(), 'Test message'); + + $logs = $aggregator->all(); + $this->assertCount(1, $logs); + $this->assertSame('771a43a4192642f0b136d5159a501700', $logs[0]->getTraceId()); + $this->assertSame('1234567890abcdef', $logs[0]->attributes()->get('sentry.trace.parent_span_id')->getValue()); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 972eab9cd8..0faab3a2fb 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -13,6 +13,7 @@ use Sentry\Metrics\Types\Metric; use Sentry\Options; use Sentry\State\HubAdapter; +use Sentry\State\Scope; use function Sentry\traceMetrics; @@ -155,4 +156,24 @@ public function testInvalidTypeIsDiscarded(): void $this->assertEmpty(StubTransport::$events); } + + public function testMetricsUseExternalPropagationContextWhenNoLocalSpanExists(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $metric = StubTransport::$events[0]->getMetrics()[0]; + $this->assertSame('771a43a4192642f0b136d5159a501700', (string) $metric->getTraceId()); + $this->assertSame('1234567890abcdef', (string) $metric->getSpanId()); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/SentrySdkExtension.php b/tests/SentrySdkExtension.php index 82ce507612..b50bc2ed85 100644 --- a/tests/SentrySdkExtension.php +++ b/tests/SentrySdkExtension.php @@ -31,6 +31,8 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + StubTransport::$events = []; + $reflectionProperty = new \ReflectionProperty(Scope::class, 'globalEventProcessors'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); @@ -40,6 +42,15 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + $reflectionProperty = new \ReflectionProperty(Scope::class, 'externalPropagationContextCallback'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + $reflectionProperty->setValue(null, null); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(false); + } + $reflectionProperty = new \ReflectionProperty(IntegrationRegistry::class, 'integrations'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); diff --git a/tests/State/ScopeTest.php b/tests/State/ScopeTest.php index f2d99db01f..7ee6ff2c88 100644 --- a/tests/State/ScopeTest.php +++ b/tests/State/ScopeTest.php @@ -8,6 +8,7 @@ use Sentry\Breadcrumb; use Sentry\Event; use Sentry\EventHint; +use Sentry\Options; use Sentry\Severity; use Sentry\State\Scope; use Sentry\Tracing\DynamicSamplingContext; @@ -528,4 +529,84 @@ public function testApplyToEvent(): void $this->assertSame('foo', $dynamicSamplingContext->get('transaction')); $this->assertSame('566e3688a61d4bc888951642d6f14a19', $dynamicSamplingContext->get('trace_id')); } + + public function testGetTraceContextPrefersExternalPropagationContextOverPropagationContext(): void + { + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $propagationContext->setSpanId(new SpanId('566e3688a61d4bc8')); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $scope = new Scope($propagationContext); + + $this->assertSame([ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], $scope->getTraceContext()); + + Scope::clearExternalPropagationContext(); + } + + public function testGetTraceContextPrefersLocalSpanOverExternalPropagationContext(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $transaction = new Transaction(new TransactionContext('foo')); + $transaction->setSpanId(new SpanId('8c2df92a922b4efe')); + $transaction->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $span = $transaction->startChild(new SpanContext()); + $span->setSpanId(new SpanId('566e3688a61d4bc8')); + + $scope = new Scope(); + $scope->setSpan($span); + + $this->assertSame([ + 'span_id' => '566e3688a61d4bc8', + 'trace_id' => '566e3688a61d4bc888951642d6f14a19', + 'origin' => 'manual', + 'parent_span_id' => '8c2df92a922b4efe', + ], $scope->getTraceContext()); + + Scope::clearExternalPropagationContext(); + } + + public function testApplyToEventSkipsDynamicSamplingContextWhenUsingExternalPropagationContext(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $scope = new Scope(); + $event = $scope->applyToEvent(Event::createEvent(), null, new Options([ + 'dsn' => 'http://public@example.com/1', + 'release' => '1.0.0', + 'environment' => 'test', + 'traces_sample_rate' => 1.0, + ])); + + $this->assertNotNull($event); + $this->assertSame([ + 'trace' => [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], + ], $event->getContexts()); + $this->assertNull($event->getSdkMetadata('dynamic_sampling_context')); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index f43b7fcf27..becfa84a8e 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -15,6 +15,7 @@ use Sentry\Event; use Sentry\EventType; use Sentry\Options; +use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\State\Scope; use Sentry\Tracing\GuzzleTracingMiddleware; @@ -33,6 +34,7 @@ public function testTraceCreatesBreadcrumbIfSpanIsNotSet(): void ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(TransactionContext::make()); @@ -77,6 +79,7 @@ public function testTraceCreatesBreadcrumbIfSpanIsRecorded(): void ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(TransactionContext::make()); @@ -118,11 +121,12 @@ public function testTraceCreatesBreadcrumbIfSpanIsRecorded(): void public function testTraceHeaders(Request $request, Options $options, bool $headersShouldBePresent): void { $client = $this->createMock(ClientInterface::class); - $client->expects($this->once()) + $client->expects($this->atLeastOnce()) ->method('getOptions') ->willReturn($options); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $expectedPromiseResult = new Response(); @@ -154,6 +158,7 @@ public function testTraceHeadersWithTransaction(Request $request, Options $optio ->willReturn($options); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(new TransactionContext()); @@ -180,6 +185,39 @@ public function testTraceHeadersWithTransaction(Request $request, Options $optio $transaction->finish(); } + public function testTraceHeadersAreNotAddedWhenExternalPropagationContextIsActive(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'trace_propagation_targets' => null, + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + $expectedPromiseResult = new Response(); + + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($expectedPromiseResult): PromiseInterface { + $this->assertEmpty($request->getHeader('sentry-trace')); + $this->assertEmpty($request->getHeader('baggage')); + + return new FulfilledPromise($expectedPromiseResult); + }); + + $function(new Request('GET', 'https://www.example.com'), []); + + Scope::clearExternalPropagationContext(); + } + public static function traceHeadersDataProvider(): iterable { // Test cases here are duplicated with sampling enabled and disabled because trace headers hould be added regardless of the sample decision @@ -282,7 +320,7 @@ public static function traceHeadersDataProvider(): iterable public function testTrace(Request $request, $expectedPromiseResult, array $expectedBreadcrumbData, array $expectedSpanData): void { $client = $this->createMock(ClientInterface::class); - $client->expects($this->exactly(4)) + $client->expects($this->atLeast(4)) ->method('getOptions') ->willReturn(new Options([ 'traces_sample_rate' => 1, @@ -292,6 +330,7 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $client->expects($this->once()) ->method('captureEvent') @@ -341,6 +380,7 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec $function = $middleware(function (Request $request) use ($expectedPromiseResult): PromiseInterface { $this->assertNotEmpty($request->getHeader('sentry-trace')); $this->assertNotEmpty($request->getHeader('baggage')); + if ($expectedPromiseResult instanceof \Throwable) { return new RejectedPromise($expectedPromiseResult); } diff --git a/tests/Util/HttpTest.php b/tests/Util/HttpTest.php index 342ad1b753..c4a390a4f8 100644 --- a/tests/Util/HttpTest.php +++ b/tests/Util/HttpTest.php @@ -10,6 +10,16 @@ final class HttpTest extends TestCase { + public function testGetSentryAuthHeader(): void + { + $dsn = Dsn::createFromString('http://public@example.com/1'); + + $this->assertSame( + 'Sentry sentry_version=7, sentry_client=sentry.sdk.identifier/1.2.3, sentry_key=public', + Http::getSentryAuthHeader($dsn, 'sentry.sdk.identifier', '1.2.3') + ); + } + /** * @dataProvider getRequestHeadersDataProvider */ @@ -26,7 +36,11 @@ public static function getRequestHeadersDataProvider(): \Generator '1.2.3', [ 'Content-Type: application/x-sentry-envelope', - 'X-Sentry-Auth: Sentry sentry_version=7, sentry_client=sentry.sdk.identifier/1.2.3, sentry_key=public', + 'X-Sentry-Auth: ' . Http::getSentryAuthHeader( + Dsn::createFromString('http://public@example.com/1'), + 'sentry.sdk.identifier', + '1.2.3' + ), ], ]; } From 8e08b86d0433b5f366fc4ccad514ed4d44ad8cc1 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 23 Mar 2026 14:13:57 +0100 Subject: [PATCH 25/89] Prepare 4.23.0 (#2033) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea268f4e2e..f7a43aabbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # CHANGELOG +## 4.23.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.23.0. + +### Features + +- Add `OTLPIntegration` support to interoperate with OpenTelemetry traces. [(#2030)](https://github.com/getsentry/sentry-php/pull/2030) + +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'integrations' => [ + new \Sentry\Integration\OTLPIntegration(), + ], +]); +``` + +- Add `log_flush_threshold` to automatically flush buffered logs after a configured number of log records. [(#2032)](https://github.com/getsentry/sentry-php/pull/2032) +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'enable_logs' => true, + 'log_flush_threshold' => 20, +]); +``` + + ## 4.22.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.22.0. From 121a674d5fffcdb8e414b75c1b76edba8e592b66 Mon Sep 17 00:00:00 2001 From: stayallive <1090754+stayallive@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:15:52 +0000 Subject: [PATCH 26/89] release: 4.23.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index d5b412bebc..26a0b04940 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.22.0'; + public const SDK_VERSION = '4.23.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From d94ee1bfb6ab8d3d1145d4d941f3e1d0c84393a3 Mon Sep 17 00:00:00 2001 From: joshuarli Date: Mon, 23 Mar 2026 23:49:31 -0700 Subject: [PATCH 27/89] chore: pin GitHub Actions to full-length commit SHAs (#2034) --- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/ci.yml | 20 ++++++++++---------- .github/workflows/publish-release.yaml | 2 +- .github/workflows/static-analysis.yaml | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 30c6083c6b..78d2c8878b 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -14,5 +14,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@f4889d04564e47311038ecb6b910fef6b6cf1363 # v2 secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 605843cbf1..1a11f8fd8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 with: php-version: ${{ matrix.php.version }} coverage: xdebug @@ -65,7 +65,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-${{ matrix.php.version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }} @@ -101,7 +101,7 @@ jobs: run: vendor/bin/phpunit --testsuite oom --no-coverage - name: Upload code coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -115,12 +115,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 with: php-version: '8.4' coverage: none @@ -131,7 +131,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-runtime-frankenphp-composer-${{ hashFiles('**/composer.lock') }} @@ -180,12 +180,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 with: php-version: '8.4' coverage: none @@ -196,7 +196,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-runtime-roadrunner-composer-${{ hashFiles('**/composer.lock') }} diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index cf172211ef..e3b771ee33 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -29,7 +29,7 @@ jobs: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 0c85d12a60..866de74bee 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 with: php-version: '8.4' @@ -34,10 +34,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 with: php-version: '8.4' From 15326478c8a404854e6af15e69d7e833d8c5056b Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Fri, 27 Mar 2026 15:34:25 +0100 Subject: [PATCH 28/89] chore: Add PR validation workflow (#2039) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 327 ++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 .github/workflows/validate-pr.yml diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 0000000000..e826cb338a --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,327 @@ +name: Validate PR + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + validate-non-maintainer-pr: + name: Validate Non-Maintainer PR + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + contents: write + outputs: + was-closed: ${{ steps.validate.outputs.was-closed }} + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 + with: + app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} + private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} + + - name: Validate PR + id: validate + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const pullRequest = context.payload.pull_request; + const repo = context.repo; + const prAuthor = pullRequest.user.login; + const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`; + + // --- Helper: check if a user has admin or maintain permission on a repo (cached) --- + const maintainerCache = new Map(); + async function isMaintainer(owner, repoName, username) { + const key = `${owner}/${repoName}:${username}`; + if (maintainerCache.has(key)) return maintainerCache.get(key); + let result = false; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo: repoName, + username, + }); + // permission field uses legacy values (admin/write/read/none) where + // maintain maps to write. Use role_name for the actual role. + result = ['admin', 'maintain'].includes(data.role_name); + } catch { + // noop — result stays false + } + maintainerCache.set(key, result); + return result; + } + + // --- Step 1: Check if PR author is a maintainer (admin or maintain role) --- + const authorIsMaintainer = await isMaintainer(repo.owner, repo.repo, prAuthor); + if (authorIsMaintainer) { + core.info(`PR author ${prAuthor} has admin/maintain access. Skipping.`); + return; + } + core.info(`PR author ${prAuthor} is not a maintainer.`); + + // --- Step 2: Parse issue references from PR body --- + const body = pullRequest.body || ''; + + // Match all issue reference formats: + // #123, Fixes #123, getsentry/repo#123, Fixes getsentry/repo#123 + // https://github.com/getsentry/repo/issues/123 + const issueRefs = []; + const seen = new Set(); + + // Pattern 1: Full GitHub URLs + const urlPattern = /https?:\/\/github\.com\/(getsentry)\/([\w.-]+)\/issues\/(\d+)/gi; + for (const match of body.matchAll(urlPattern)) { + const key = `${match[1]}/${match[2]}#${match[3]}`; + if (!seen.has(key)) { + seen.add(key); + issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); + } + } + + // Pattern 2: Cross-repo references (getsentry/repo#123) + const crossRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(getsentry)\/([\w.-]+)#(\d+)/gi; + for (const match of body.matchAll(crossRepoPattern)) { + const key = `${match[1]}/${match[2]}#${match[3]}`; + if (!seen.has(key)) { + seen.add(key); + issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); + } + } + + // Pattern 3: Same-repo references (#123) + // Negative lookbehind to avoid matching cross-repo refs or URLs already captured + const sameRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(? 0) { + const assignedToAuthor = issue.assignees.some(a => a.login === prAuthor); + if (!assignedToAuthor) { + core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} is assigned to someone else.`); + hasAssigneeConflict = true; + continue; + } + } + + // Check discussion: both PR author and a maintainer must have commented + const comments = await github.paginate(github.rest.issues.listComments, { + owner: ref.owner, + repo: ref.repo, + issue_number: ref.number, + per_page: 100, + }); + + // Also consider the issue author as a participant (opening the issue is a form of discussion) + // Guard against null user (deleted/suspended GitHub accounts) + const prAuthorParticipated = + issue.user?.login === prAuthor || + comments.some(c => c.user?.login === prAuthor); + + let maintainerParticipated = false; + if (prAuthorParticipated) { + // Check each commenter (and issue author) for admin/maintain access on the issue's repo + const usersToCheck = new Set(); + if (issue.user?.login) usersToCheck.add(issue.user.login); + for (const comment of comments) { + if (comment.user?.login && comment.user.login !== prAuthor) { + usersToCheck.add(comment.user.login); + } + } + + for (const user of usersToCheck) { + if (user === prAuthor) continue; + if (await isMaintainer(repo.owner, repo.repo, user)) { + maintainerParticipated = true; + core.info(`Maintainer ${user} participated in ${ref.owner}/${ref.repo}#${ref.number}.`); + break; + } + } + } + + if (prAuthorParticipated && maintainerParticipated) { + core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} has valid discussion. PR is allowed.`); + return; // PR is valid — at least one issue passes all checks + } + + core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} lacks discussion between author and maintainer.`); + hasNoDiscussion = true; + } + + // --- Step 5: No valid issue found — close with the most relevant reason --- + if (hasAssigneeConflict) { + core.info('Closing PR: referenced issue is assigned to someone else.'); + await closePR([ + 'This PR has been automatically closed. The referenced issue is already assigned to someone else.', + '', + 'If you believe this assignment is outdated, please comment on the issue to discuss before opening a new PR.', + '', + `Please review our [contributing guidelines](${contributingUrl}) for more details.`, + ].join('\n'), 'issue-already-assigned'); + return; + } + + if (hasNoDiscussion) { + core.info('Closing PR: no discussion between PR author and a maintainer in the referenced issue.'); + await closePR([ + 'This PR has been automatically closed. The referenced issue does not show a discussion between you and a maintainer.', + '', + 'To avoid wasted effort on both sides, please discuss your proposed approach in the issue first and wait for a maintainer to respond before opening a PR.', + '', + `Please review our [contributing guidelines](${contributingUrl}) for more details.`, + ].join('\n'), 'missing-maintainer-discussion'); + return; + } + + // If we get here, all issue refs were unfetchable + core.info('Could not validate any referenced issues. Closing PR.'); + await closePR([ + 'This PR has been automatically closed. The referenced issue(s) could not be found.', + '', + '**Next steps:**', + '1. Ensure the issue exists and is in a `getsentry` repository', + '2. Discuss the approach with a maintainer in the issue', + '3. Once a maintainer has acknowledged your proposed approach, open a new PR referencing the issue', + '', + `Please review our [contributing guidelines](${contributingUrl}) for more details.`, + ].join('\n'), 'missing-issue-reference'); + + enforce-draft: + name: Enforce Draft PR + needs: [validate-non-maintainer-pr] + if: | + always() + && github.event.pull_request.draft == false + && needs.validate-non-maintainer-pr.outputs.was-closed != 'true' + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + contents: write + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 + with: + app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} + private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} + + - name: Convert PR to draft + env: + GH_TOKEN: ${{github.token}} + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + gh pr ready "$PR_URL" --undo + + - name: Label and comment + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const pullRequest = context.payload.pull_request; + const repo = context.repo; + + // Label the PR so maintainers can filter/track violations + await github.rest.issues.addLabels({ + ...repo, + issue_number: pullRequest.number, + labels: ['converted-to-draft'], + }); + + // Check for existing bot comment to avoid duplicates on reopen + const comments = await github.rest.issues.listComments({ + ...repo, + issue_number: pullRequest.number, + }); + const botComment = comments.data.find(c => + c.user.type === 'Bot' && + c.body.includes('automatically converted to draft') + ); + if (botComment) { + core.info('Bot comment already exists, skipping.'); + return; + } + + const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`; + + await github.rest.issues.createComment({ + ...repo, + issue_number: pullRequest.number, + body: [ + `This PR has been automatically converted to draft. All PRs must start as drafts per our [contributing guidelines](${contributingUrl}).`, + '', + '**Next steps:**', + '1. Ensure CI passes', + '2. Fill in the PR description completely', + '3. Mark as "Ready for review" when you\'re done' + ].join('\n') + }); From 6c3eb0eee0e00ec952dcd470a0e9d20d3a448f0e Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Fri, 27 Mar 2026 16:31:27 +0100 Subject: [PATCH 29/89] fix(logs): use `server.address` instead of `sentry.server.address` (#2040) --- src/Logs/LogsAggregator.php | 2 +- tests/Logs/LogsAggregatorTest.php | 4 ++-- tests/Monolog/LogsHandlerTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 6e7136e06b..9d19b08a11 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -79,7 +79,7 @@ public function add( $log = (new Log($timestamp, $traceId, $level, $formattedMessage)) ->setAttribute('sentry.release', $options->getRelease()) ->setAttribute('sentry.environment', $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT) - ->setAttribute('sentry.server.address', $options->getServerName()) + ->setAttribute('server.address', $options->getServerName()) ->setAttribute('sentry.trace.parent_span_id', $parentSpanId); if ($client instanceof Client) { diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index 8f47ce48da..2082034320 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -54,7 +54,7 @@ public function testAttributes(array $attributes, array $expected): void $log->attributes()->toSimpleArray(), static function (string $key) { // We are not testing internal Sentry attributes here, only the ones the user supplied - return !str_starts_with($key, 'sentry.'); + return !str_starts_with($key, 'sentry.') && $key !== 'server.address'; }, \ARRAY_FILTER_USE_KEY ) @@ -200,7 +200,7 @@ public function testAttributesAreAddedToLogMessage(): void $this->assertSame('1.0.0', $attributes->get('sentry.release')->getValue()); $this->assertSame('production', $attributes->get('sentry.environment')->getValue()); - $this->assertSame('web-server-01', $attributes->get('sentry.server.address')->getValue()); + $this->assertSame('web-server-01', $attributes->get('server.address')->getValue()); $this->assertSame('User %s performed action %s', $attributes->get('sentry.message.template')->getValue()); $this->assertSame('566e3688a61d4bc8', $attributes->get('sentry.trace.parent_span_id')->getValue()); $this->assertSame('sentry.php', $attributes->get('sentry.sdk.name')->getValue()); diff --git a/tests/Monolog/LogsHandlerTest.php b/tests/Monolog/LogsHandlerTest.php index 004a80c38e..e77aa68099 100644 --- a/tests/Monolog/LogsHandlerTest.php +++ b/tests/Monolog/LogsHandlerTest.php @@ -54,7 +54,7 @@ public function testHandle($record, Log $expectedLog): void $log->attributes()->toSimpleArray(), static function (string $key) { // We are not testing Sentry's own attributes here, only the ones the user supplied so filter them out of the expected attributes - return !str_starts_with($key, 'sentry.'); + return !str_starts_with($key, 'sentry.') && $key !== 'server.address'; }, \ARRAY_FILTER_USE_KEY ) From 352e531efa8fe07ef266eb7315fc7ada2ac6c9bf Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Fri, 27 Mar 2026 19:25:41 +0100 Subject: [PATCH 30/89] Prepare 4.23.1 (#2041) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a43aabbe..8679680e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 4.23.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.23.1. + +### Bug Fixes + +- Use `server.address` log attribute instead of `sentry.server.address`. [(#2040)](https://github.com/getsentry/sentry-php/pull/2040) + ## 4.23.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.23.0. From eccf30d8abcf55189a1dc0eb3d946a61d42fb4df Mon Sep 17 00:00:00 2001 From: stayallive <1090754+stayallive@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:26:46 +0000 Subject: [PATCH 31/89] release: 4.23.1 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 26a0b04940..e637850942 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.23.0'; + public const SDK_VERSION = '4.23.1'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From a334e461a310f3d7c29b7e8da11390b6725f5eca Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Fri, 27 Mar 2026 20:47:45 +0100 Subject: [PATCH 32/89] chore: Use shared validate-pr composite action (#2042) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 315 +----------------------------- 1 file changed, 2 insertions(+), 313 deletions(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index e826cb338a..c05657993e 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -5,323 +5,12 @@ on: types: [opened, reopened] jobs: - validate-non-maintainer-pr: - name: Validate Non-Maintainer PR + validate-pr: runs-on: ubuntu-24.04 permissions: pull-requests: write - contents: write - outputs: - was-closed: ${{ steps.validate.outputs.was-closed }} steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 + - uses: getsentry/github-workflows/validate-pr@4243265ac9cc3ee5b89ad2b30c3797ac8483d63a with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} - - - name: Validate PR - id: validate - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const pullRequest = context.payload.pull_request; - const repo = context.repo; - const prAuthor = pullRequest.user.login; - const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`; - - // --- Helper: check if a user has admin or maintain permission on a repo (cached) --- - const maintainerCache = new Map(); - async function isMaintainer(owner, repoName, username) { - const key = `${owner}/${repoName}:${username}`; - if (maintainerCache.has(key)) return maintainerCache.get(key); - let result = false; - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo: repoName, - username, - }); - // permission field uses legacy values (admin/write/read/none) where - // maintain maps to write. Use role_name for the actual role. - result = ['admin', 'maintain'].includes(data.role_name); - } catch { - // noop — result stays false - } - maintainerCache.set(key, result); - return result; - } - - // --- Step 1: Check if PR author is a maintainer (admin or maintain role) --- - const authorIsMaintainer = await isMaintainer(repo.owner, repo.repo, prAuthor); - if (authorIsMaintainer) { - core.info(`PR author ${prAuthor} has admin/maintain access. Skipping.`); - return; - } - core.info(`PR author ${prAuthor} is not a maintainer.`); - - // --- Step 2: Parse issue references from PR body --- - const body = pullRequest.body || ''; - - // Match all issue reference formats: - // #123, Fixes #123, getsentry/repo#123, Fixes getsentry/repo#123 - // https://github.com/getsentry/repo/issues/123 - const issueRefs = []; - const seen = new Set(); - - // Pattern 1: Full GitHub URLs - const urlPattern = /https?:\/\/github\.com\/(getsentry)\/([\w.-]+)\/issues\/(\d+)/gi; - for (const match of body.matchAll(urlPattern)) { - const key = `${match[1]}/${match[2]}#${match[3]}`; - if (!seen.has(key)) { - seen.add(key); - issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); - } - } - - // Pattern 2: Cross-repo references (getsentry/repo#123) - const crossRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(getsentry)\/([\w.-]+)#(\d+)/gi; - for (const match of body.matchAll(crossRepoPattern)) { - const key = `${match[1]}/${match[2]}#${match[3]}`; - if (!seen.has(key)) { - seen.add(key); - issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); - } - } - - // Pattern 3: Same-repo references (#123) - // Negative lookbehind to avoid matching cross-repo refs or URLs already captured - const sameRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(? 0) { - const assignedToAuthor = issue.assignees.some(a => a.login === prAuthor); - if (!assignedToAuthor) { - core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} is assigned to someone else.`); - hasAssigneeConflict = true; - continue; - } - } - - // Check discussion: both PR author and a maintainer must have commented - const comments = await github.paginate(github.rest.issues.listComments, { - owner: ref.owner, - repo: ref.repo, - issue_number: ref.number, - per_page: 100, - }); - - // Also consider the issue author as a participant (opening the issue is a form of discussion) - // Guard against null user (deleted/suspended GitHub accounts) - const prAuthorParticipated = - issue.user?.login === prAuthor || - comments.some(c => c.user?.login === prAuthor); - - let maintainerParticipated = false; - if (prAuthorParticipated) { - // Check each commenter (and issue author) for admin/maintain access on the issue's repo - const usersToCheck = new Set(); - if (issue.user?.login) usersToCheck.add(issue.user.login); - for (const comment of comments) { - if (comment.user?.login && comment.user.login !== prAuthor) { - usersToCheck.add(comment.user.login); - } - } - - for (const user of usersToCheck) { - if (user === prAuthor) continue; - if (await isMaintainer(repo.owner, repo.repo, user)) { - maintainerParticipated = true; - core.info(`Maintainer ${user} participated in ${ref.owner}/${ref.repo}#${ref.number}.`); - break; - } - } - } - - if (prAuthorParticipated && maintainerParticipated) { - core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} has valid discussion. PR is allowed.`); - return; // PR is valid — at least one issue passes all checks - } - - core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} lacks discussion between author and maintainer.`); - hasNoDiscussion = true; - } - - // --- Step 5: No valid issue found — close with the most relevant reason --- - if (hasAssigneeConflict) { - core.info('Closing PR: referenced issue is assigned to someone else.'); - await closePR([ - 'This PR has been automatically closed. The referenced issue is already assigned to someone else.', - '', - 'If you believe this assignment is outdated, please comment on the issue to discuss before opening a new PR.', - '', - `Please review our [contributing guidelines](${contributingUrl}) for more details.`, - ].join('\n'), 'issue-already-assigned'); - return; - } - - if (hasNoDiscussion) { - core.info('Closing PR: no discussion between PR author and a maintainer in the referenced issue.'); - await closePR([ - 'This PR has been automatically closed. The referenced issue does not show a discussion between you and a maintainer.', - '', - 'To avoid wasted effort on both sides, please discuss your proposed approach in the issue first and wait for a maintainer to respond before opening a PR.', - '', - `Please review our [contributing guidelines](${contributingUrl}) for more details.`, - ].join('\n'), 'missing-maintainer-discussion'); - return; - } - - // If we get here, all issue refs were unfetchable - core.info('Could not validate any referenced issues. Closing PR.'); - await closePR([ - 'This PR has been automatically closed. The referenced issue(s) could not be found.', - '', - '**Next steps:**', - '1. Ensure the issue exists and is in a `getsentry` repository', - '2. Discuss the approach with a maintainer in the issue', - '3. Once a maintainer has acknowledged your proposed approach, open a new PR referencing the issue', - '', - `Please review our [contributing guidelines](${contributingUrl}) for more details.`, - ].join('\n'), 'missing-issue-reference'); - - enforce-draft: - name: Enforce Draft PR - needs: [validate-non-maintainer-pr] - if: | - always() - && github.event.pull_request.draft == false - && needs.validate-non-maintainer-pr.outputs.was-closed != 'true' - runs-on: ubuntu-24.04 - permissions: - pull-requests: write - contents: write - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 - with: - app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} - private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} - - - name: Convert PR to draft - env: - GH_TOKEN: ${{github.token}} - PR_URL: ${{ github.event.pull_request.html_url }} - run: | - gh pr ready "$PR_URL" --undo - - - name: Label and comment - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const pullRequest = context.payload.pull_request; - const repo = context.repo; - - // Label the PR so maintainers can filter/track violations - await github.rest.issues.addLabels({ - ...repo, - issue_number: pullRequest.number, - labels: ['converted-to-draft'], - }); - - // Check for existing bot comment to avoid duplicates on reopen - const comments = await github.rest.issues.listComments({ - ...repo, - issue_number: pullRequest.number, - }); - const botComment = comments.data.find(c => - c.user.type === 'Bot' && - c.body.includes('automatically converted to draft') - ); - if (botComment) { - core.info('Bot comment already exists, skipping.'); - return; - } - - const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`; - - await github.rest.issues.createComment({ - ...repo, - issue_number: pullRequest.number, - body: [ - `This PR has been automatically converted to draft. All PRs must start as drafts per our [contributing guidelines](${contributingUrl}).`, - '', - '**Next steps:**', - '1. Ensure CI passes', - '2. Fill in the PR description completely', - '3. Mark as "Ready for review" when you\'re done' - ].join('\n') - }); From 6f99de6994f030640ad69e8b7eaae87baf21a607 Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Fri, 27 Mar 2026 22:43:41 +0100 Subject: [PATCH 33/89] chore: Update validate-pr action to latest version (#2043) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index c05657993e..3b82dd4026 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@4243265ac9cc3ee5b89ad2b30c3797ac8483d63a + - uses: getsentry/github-workflows/validate-pr@4ff40ada546d4a31b852a4279828b989a6193497 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 918353cb51c65fc7fd4040c94e6cbcda7e3977ff Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Mon, 30 Mar 2026 12:14:05 +0200 Subject: [PATCH 34/89] fix(ci): Update validate-pr action to remove draft enforcement (#2045) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 3b82dd4026..44da67faa4 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@4ff40ada546d4a31b852a4279828b989a6193497 + - uses: getsentry/github-workflows/validate-pr@0b52fc6a867b744dcbdf5d25c18bc8d1c95710e1 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 49a6bbeb1b6ab05bec9a08eb7c13b0b975f21ff8 Mon Sep 17 00:00:00 2001 From: Jarred Stelfox Date: Mon, 30 Mar 2026 10:05:08 -0700 Subject: [PATCH 35/89] fix: Serialize native PHP enums with name and value instead of opaque 'Object' string (#2038) Co-authored-by: Claude Opus 4.6 (1M context) --- src/Serializer/AbstractSerializer.php | 11 +++- tests/Serializer/AbstractSerializerTest.php | 50 +++++++++++++++++++ tests/Serializer/SerializerTestBackedEnum.php | 10 ++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/Serializer/SerializerTestBackedEnum.php diff --git a/src/Serializer/AbstractSerializer.php b/src/Serializer/AbstractSerializer.php index 9e63123254..f5c1199420 100644 --- a/src/Serializer/AbstractSerializer.php +++ b/src/Serializer/AbstractSerializer.php @@ -139,6 +139,10 @@ protected function serializeRecursively($value, int $_depth = 0) return $this->formatDate($value); } + if ($value instanceof \UnitEnum) { + return $this->serializeValue($value); + } + if ($this->serializeAllObjects || ($value instanceof \stdClass)) { return $this->serializeObject($value, $_depth); } @@ -247,8 +251,13 @@ protected function serializeValue($value) if ($value instanceof \UnitEnum) { $reflection = new \ReflectionObject($value); + $enumValue = $reflection->getName() . '::' . $value->name; + + if ($value instanceof \BackedEnum) { + return 'Enum ' . $enumValue . '(' . $value->value . ')'; + } - return 'Enum ' . $reflection->getName() . '::' . $value->name; + return 'Enum ' . $enumValue; } if (\is_object($value)) { diff --git a/tests/Serializer/AbstractSerializerTest.php b/tests/Serializer/AbstractSerializerTest.php index 89932ba1ae..f2e447a706 100644 --- a/tests/Serializer/AbstractSerializerTest.php +++ b/tests/Serializer/AbstractSerializerTest.php @@ -66,6 +66,56 @@ public function testEnumsAreNames(): void $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestEnum::CASE_NAME', $result); } + /** + * @requires PHP >= 8.1 + */ + public function testBackedEnumsIncludeValue(): void + { + $serializer = $this->createSerializer(); + $input = SerializerTestBackedEnum::CASE_NAME; + $result = $this->invokeSerialization($serializer, $input); + + $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestBackedEnum::CASE_NAME(case_value)', $result); + } + + /** + * @requires PHP >= 8.1 + * + * @dataProvider serializeAllObjectsDataProvider + */ + public function testEnumsAreNotSerializedAsObjects(bool $serializeAllObjects): void + { + $serializer = $this->createSerializer(); + + if ($serializeAllObjects) { + $serializer->setSerializeAllObjects(true); + } + + $input = SerializerTestEnum::CASE_NAME; + $result = $this->invokeSerialization($serializer, $input); + + $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestEnum::CASE_NAME', $result); + } + + /** + * @requires PHP >= 8.1 + * + * @dataProvider serializeAllObjectsDataProvider + */ + public function testBackedEnumsAreNotSerializedAsObjects(bool $serializeAllObjects): void + { + $serializer = $this->createSerializer(); + + if ($serializeAllObjects) { + $serializer->setSerializeAllObjects(true); + } + + $input = SerializerTestBackedEnum::CASE_NAME; + $result = $this->invokeSerialization($serializer, $input); + + $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestBackedEnum::CASE_NAME(case_value)', $result); + } + public static function objectsWithIdPropertyDataProvider(): array { return [ diff --git a/tests/Serializer/SerializerTestBackedEnum.php b/tests/Serializer/SerializerTestBackedEnum.php new file mode 100644 index 0000000000..7a4ee78ac6 --- /dev/null +++ b/tests/Serializer/SerializerTestBackedEnum.php @@ -0,0 +1,10 @@ + Date: Mon, 30 Mar 2026 19:54:13 +0200 Subject: [PATCH 36/89] fix: do not distribute AGENTS.md and CLAUDE.md (#2046) --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitattributes b/.gitattributes index 5b30cfa974..be6d279570 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,6 +12,8 @@ # Exclude non-essential files from dist /tests export-ignore /stubs export-ignore +/AGENTS.md export-ignore +/CLAUDE.md export-ignore /.appveyor.yml export-ignore /.craft.yml export-ignore /.editorconfig export-ignore From 40ee3052ef6f8a1fd45fde0698b69d7e2dd86855 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:15:27 +0200 Subject: [PATCH 37/89] chore(deps): bump codecov/codecov-action from 5.5.3 to 6.0.0 (#2050) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a11f8fd8d..7901f62b68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: run: vendor/bin/phpunit --testsuite oom --no-coverage - name: Upload code coverage to Codecov - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} From f4af588dc983cd4ebf2dde5df7aa3adb6926be95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:15:50 +0200 Subject: [PATCH 38/89] chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.25.0 to 2.25.2 (#2049) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/changelog-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 78d2c8878b..43e2582947 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -14,5 +14,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@f4889d04564e47311038ecb6b910fef6b6cf1363 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@ba01e596c4a4c07692f0de10b0d4fe05f3dd0292 # v2 secrets: inherit From 80b9818ec1684ce931c557689951d36637abaadb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:16:10 +0200 Subject: [PATCH 39/89] chore(deps): bump getsentry/craft from 2.24.2 to 2.25.2 (#2048) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index e3b771ee33..e63324944d 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@78da70b88de5cf6245d5d7e3263a9e8952667dec + uses: getsentry/craft@ba01e596c4a4c07692f0de10b0d4fe05f3dd0292 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 0e6c1c1dbb4f87965c634390df3ccda35a53cb72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:16:31 +0200 Subject: [PATCH 40/89] chore(deps): bump shivammathur/setup-php from 728c6c6b8cf02c2e48117716a91ee48313958a19 to accd6127cb78bee3e8082180cb391013d204ef9f (#2047) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/static-analysis.yaml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7901f62b68..5e877ac0e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 with: php-version: ${{ matrix.php.version }} coverage: xdebug @@ -120,7 +120,7 @@ jobs: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 with: php-version: '8.4' coverage: none @@ -185,7 +185,7 @@ jobs: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 with: php-version: '8.4' coverage: none diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 866de74bee..ce91800b62 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup PHP - uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 with: php-version: '8.4' @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup PHP - uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 with: php-version: '8.4' From 71a4dc736052b1f66ab577143264484fb12f4e00 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 1 Apr 2026 10:15:00 +0200 Subject: [PATCH 41/89] ref: deprecate old sentry handler (#2051) --- src/Monolog/Handler.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Monolog/Handler.php b/src/Monolog/Handler.php index 3e8d52bba1..11fb16ae54 100644 --- a/src/Monolog/Handler.php +++ b/src/Monolog/Handler.php @@ -16,6 +16,9 @@ * This Monolog handler logs every message to a Sentry's server using the given * hub instance. * + * @deprecated since version 4.24. To be removed in version 5.0. Use {@see LogsHandler} + * with the `enable_logs` SDK option instead. + * * @author Stefano Arlandini */ final class Handler extends AbstractProcessingHandler From 06784a79d8286dc9d22b0748eaae63b8e58f4849 Mon Sep 17 00:00:00 2001 From: Stephanie Anderson Date: Wed, 1 Apr 2026 14:37:23 +0200 Subject: [PATCH 42/89] chore: Update validate-pr workflow (#2052) Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 44da67faa4..10fe894067 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@0b52fc6a867b744dcbdf5d25c18bc8d1c95710e1 + - uses: getsentry/github-workflows/validate-pr@71588ddf95134f804e82c5970a8098588e2eaecd with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From bec9d03a289e49299f61e746e2cd79fadcf15303 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 1 Apr 2026 16:33:08 +0200 Subject: [PATCH 43/89] Prepare 4.24.0 (#2053) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8679680e7d..62f714ac2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +## 4.24.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.24.0. + +### Bug Fixes + +- Serialize native PHP enums as readable enum strings, including backed enum values, instead of opaque `Object` strings. [(#2038)](https://github.com/getsentry/sentry-php/pull/2038) +- Exclude `AGENTS.md` and `CLAUDE.md` from distribution archives. [(#2046)](https://github.com/getsentry/sentry-php/pull/2046) + +### Misc + +- Deprecate `Sentry\Monolog\Handler` in favor of `Sentry\Monolog\LogsHandler` with the `enable_logs` SDK option. [(#2051)](https://github.com/getsentry/sentry-php/pull/2051) + ## 4.23.1 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.23.1. From 5055cc792f630a427fdf9dcccd88faa94b50fd30 Mon Sep 17 00:00:00 2001 From: Litarnus <8436563+Litarnus@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:34:34 +0000 Subject: [PATCH 44/89] release: 4.24.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index e637850942..fce324fc8d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.23.1'; + public const SDK_VERSION = '4.24.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 4489a03a014f919a130ec30fe54c6cba9f8b3734 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 2 Apr 2026 14:00:26 +0200 Subject: [PATCH 45/89] ref: add `ext-excimer` as composer suggestion (#2057) --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 0bfd60f6c3..121fa5817e 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "spiral/roadrunner-worker": "^3.6" }, "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." }, "conflict": { From f9f8d130b145da0e7868e8ee1e8ca0148750e6ef Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 7 Apr 2026 11:30:47 +0200 Subject: [PATCH 46/89] ref(logs): use RingBuffer for logs when not using `log_flush_threshold` (#2058) --- src/Logs/LogsAggregator.php | 39 +++++++--- src/Util/TelemetryStorage.php | 109 ++++++++++++++++++++++++++++ tests/Util/TelemetryStorageTest.php | 78 ++++++++++++++++++++ 3 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 src/Util/TelemetryStorage.php create mode 100644 tests/Util/TelemetryStorageTest.php diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 9d19b08a11..bc0b5f302a 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -13,16 +13,19 @@ use Sentry\State\Scope; use Sentry\Util\Arr; use Sentry\Util\Str; +use Sentry\Util\TelemetryStorage; /** * @internal */ final class LogsAggregator { + private const LOGS_BUFFER_SIZE = 1000; + /** - * @var Log[] + * @var TelemetryStorage|null */ - private $logs = []; + private $logs; /** * @param string $message see sprintf for a description of format @@ -155,25 +158,24 @@ public function add( $sdkLogger->log($log->getPsrLevel(), "Logs item: {$log->getBody()}", $log->attributes()->toSimpleArray()); } - $this->logs[] = $log; - $logFlushThreshold = $options->getLogFlushThreshold(); + $logs = $this->getStorage($logFlushThreshold); - if ($logFlushThreshold !== null && \count($this->logs) >= $logFlushThreshold) { + $logs->push($log); + + if ($logFlushThreshold !== null && \count($logs) >= $logFlushThreshold) { $this->flush($hub); } } public function flush(?HubInterface $hub = null): ?EventId { - if (empty($this->logs)) { + if ($this->logs === null || $this->logs->isEmpty()) { return null; } $hub = $hub ?? SentrySdk::getCurrentHub(); - $event = Event::createLogs()->setLogs($this->logs); - - $this->logs = []; + $event = Event::createLogs()->setLogs($this->logs->drain()); return $hub->captureEvent($event); } @@ -183,7 +185,7 @@ public function flush(?HubInterface $hub = null): ?EventId */ public function all(): array { - return $this->logs; + return $this->logs !== null ? $this->logs->toArray() : []; } /** @@ -223,4 +225,21 @@ private function getTraceData(HubInterface $hub): array /** @var array{trace_id: string, parent_span_id: string|null} $traceData */ return $traceData; } + + /** + * @return TelemetryStorage + */ + private function getStorage(?int $logFlushThreshold = null): TelemetryStorage + { + if ($this->logs === null) { + /** @var TelemetryStorage $logs */ + $logs = $logFlushThreshold !== null + ? TelemetryStorage::unbounded() + : TelemetryStorage::bounded(self::LOGS_BUFFER_SIZE); + + $this->logs = $logs; + } + + return $this->logs; + } } diff --git a/src/Util/TelemetryStorage.php b/src/Util/TelemetryStorage.php new file mode 100644 index 0000000000..2289493305 --- /dev/null +++ b/src/Util/TelemetryStorage.php @@ -0,0 +1,109 @@ + + */ + private $data; + + private function __construct(?int $size = null) + { + if ($size !== null) { + $this->data = new RingBuffer($size); + } else { + $this->data = []; + } + } + + public function count(): int + { + return \count($this->data); + } + + /** + * @param T $value + */ + public function push($value): void + { + if ($this->data instanceof RingBuffer) { + $this->data->push($value); + } else { + $this->data[] = $value; + } + } + + /** + * @return T[] + */ + public function drain(): array + { + if ($this->data instanceof RingBuffer) { + return $this->data->drain(); + } + $data = $this->data; + $this->data = []; + + return $data; + } + + /** + * @return T[] + */ + public function toArray(): array + { + if ($this->data instanceof RingBuffer) { + return $this->data->toArray(); + } + + return $this->data; + } + + public function isEmpty(): bool + { + if ($this->data instanceof RingBuffer) { + return $this->data->isEmpty(); + } + + return empty($this->data); + } + + /** + * Creates a new TelemetryStorage that is not bounded in size. This version should only be used if there + * is another flushing signal available. + * + * @return self + */ + public static function unbounded(): self + { + return new self(); + } + + /** + * Creates a TelemetryStorage that has an upper bound of $size. It will drop the oldest items when new items + * are added while being at capacity. + * + * @return self + */ + public static function bounded(int $size): self + { + return new self($size); + } +} diff --git a/tests/Util/TelemetryStorageTest.php b/tests/Util/TelemetryStorageTest.php new file mode 100644 index 0000000000..82571e89b4 --- /dev/null +++ b/tests/Util/TelemetryStorageTest.php @@ -0,0 +1,78 @@ +push('foo'); + $storage->push('bar'); + + $result = $storage->toArray(); + $this->assertSame(2, $storage->count()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testUnboundedDrainClearsStorage(): void + { + $storage = TelemetryStorage::unbounded(); + $storage->push('foo'); + $storage->push('bar'); + + $this->assertSame(2, $storage->count()); + $result = $storage->drain(); + $this->assertTrue($storage->isEmpty()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testUnboundedIsEmpty(): void + { + $storage = TelemetryStorage::unbounded(); + $this->assertTrue($storage->isEmpty()); + + $storage->push('foo'); + + $this->assertFalse($storage->isEmpty()); + } + + public function testBoundedCapacityOverwritesOldestItems(): void + { + $storage = TelemetryStorage::bounded(2); + $storage->push('foo'); + $storage->push('bar'); + $storage->push('baz'); + + $this->assertSame(2, $storage->count()); + $this->assertEquals(['bar', 'baz'], $storage->toArray()); + } + + public function testBoundedDrainReturnsLogicalOrderAndClearsStorage(): void + { + $storage = TelemetryStorage::bounded(2); + $storage->push('foo'); + $storage->push('bar'); + $storage->push('baz'); + + $this->assertSame(2, $storage->count()); + $result = $storage->drain(); + $this->assertTrue($storage->isEmpty()); + $this->assertEquals(['bar', 'baz'], $result); + } + + public function testBoundedCapacityOneKeepsLatestItem(): void + { + $storage = TelemetryStorage::bounded(1); + $storage->push('foo'); + $storage->push('bar'); + + $this->assertCount(1, $storage); + $this->assertEquals(['bar'], $storage->toArray()); + } +} From 71c84ebdefcd95135f2937e92ca84efa21c6b8d9 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 7 Apr 2026 16:06:39 +0200 Subject: [PATCH 47/89] feat(metrics): add `metric_flush_threshold` flag (#2059) --- src/Metrics/MetricsAggregator.php | 54 +++++++++++++++++++++--------- src/Options.php | 39 +++++++++++++++++++++ src/functions.php | 1 + tests/Metrics/TraceMetricsTest.php | 46 ++++++++++++++++++++++++- tests/OptionsTest.php | 41 +++++++++++++++++++++++ 5 files changed, 164 insertions(+), 17 deletions(-) diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index 47bfcb64d0..6f763e1ddf 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -17,7 +17,7 @@ use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; use Sentry\Unit; -use Sentry\Util\RingBuffer; +use Sentry\Util\TelemetryStorage; /** * @internal @@ -29,22 +29,17 @@ final class MetricsAggregator */ public const METRICS_BUFFER_SIZE = 1000; - /** - * @var RingBuffer - */ - private $metrics; - - public function __construct() - { - $this->metrics = new RingBuffer(self::METRICS_BUFFER_SIZE); - } - private const METRIC_TYPES = [ CounterMetric::TYPE => CounterMetric::class, DistributionMetric::TYPE => DistributionMetric::class, GaugeMetric::TYPE => GaugeMetric::class, ]; + /** + * @var TelemetryStorage|null + */ + private $metrics; + /** * @param int|float $value * @param array $attributes @@ -58,6 +53,7 @@ public function add( ): void { $hub = SentrySdk::getCurrentHub(); $client = $hub->getClient(); + $metricFlushThreshold = null; if (!\is_int($value) && !\is_float($value)) { if ($client !== null) { @@ -67,20 +63,24 @@ public function add( return; } - if ($client instanceof Client) { + if ($client !== null) { $options = $client->getOptions(); + $metricFlushThreshold = $options->getMetricFlushThreshold(); if ($options->getEnableMetrics() === false) { return; } $defaultAttributes = [ - 'sentry.sdk.name' => $client->getSdkIdentifier(), - 'sentry.sdk.version' => $client->getSdkVersion(), 'sentry.environment' => $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT, 'server.address' => $options->getServerName(), ]; + if ($client instanceof Client) { + $defaultAttributes['sentry.sdk.name'] = $client->getSdkIdentifier(); + $defaultAttributes['sentry.sdk.version'] = $client->getSdkVersion(); + } + if ($options->shouldSendDefaultPii()) { $hub->configureScope(static function (Scope $scope) use (&$defaultAttributes) { $user = $scope->getUser(); @@ -122,12 +122,17 @@ public function add( } } - $this->metrics->push($metric); + $metrics = $this->getStorage($metricFlushThreshold); + $metrics->push($metric); + + if ($metricFlushThreshold !== null && \count($metrics) >= $metricFlushThreshold) { + $this->flush($hub); + } } public function flush(?HubInterface $hub = null): ?EventId { - if ($this->metrics->isEmpty()) { + if ($this->metrics === null || $this->metrics->isEmpty()) { return null; } @@ -151,4 +156,21 @@ private function getTraceContext(HubInterface $hub): array /** @var array{trace_id: string, span_id: string} $traceContext */ return $traceContext; } + + /** + * @return TelemetryStorage + */ + private function getStorage(?int $metricFlushThreshold = null): TelemetryStorage + { + if ($this->metrics === null) { + /** @var TelemetryStorage $metrics */ + $metrics = $metricFlushThreshold !== null + ? TelemetryStorage::unbounded() + : TelemetryStorage::bounded(self::METRICS_BUFFER_SIZE); + + $this->metrics = $metrics; + } + + return $this->metrics; + } } diff --git a/src/Options.php b/src/Options.php index 853d479194..c6398433d1 100644 --- a/src/Options.php +++ b/src/Options.php @@ -210,6 +210,32 @@ public function setLogFlushThreshold(?int $logFlushThreshold): self return $this; } + /** + * Gets the number of buffered metrics that trigger an immediate flush. + */ + public function getMetricFlushThreshold(): ?int + { + /** + * @var int|null $metricFlushThreshold + */ + $metricFlushThreshold = $this->options['metric_flush_threshold']; + + return $metricFlushThreshold; + } + + /** + * Sets the number of buffered metrics that trigger an immediate flush. + * null will never trigger an immediate flush. + */ + public function setMetricFlushThreshold(?int $metricFlushThreshold): self + { + $options = array_merge($this->options, ['metric_flush_threshold' => $metricFlushThreshold]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Sets if metrics should be enabled or not. */ @@ -1365,6 +1391,7 @@ private function configureOptions(OptionsResolver $resolver): void 'enable_logs' => false, 'log_flush_threshold' => null, 'enable_metrics' => true, + 'metric_flush_threshold' => null, 'traces_sample_rate' => null, 'traces_sampler' => null, 'profiles_sample_rate' => null, @@ -1443,6 +1470,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('enable_logs', 'bool'); $resolver->setAllowedTypes('log_flush_threshold', ['null', 'int']); $resolver->setAllowedTypes('enable_metrics', 'bool'); + $resolver->setAllowedTypes('metric_flush_threshold', ['null', 'int']); $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); @@ -1496,6 +1524,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); $resolver->setAllowedValues('context_lines', \Closure::fromCallable([$this, 'validateContextLinesOption'])); $resolver->setAllowedValues('log_flush_threshold', \Closure::fromCallable([$this, 'validateLogFlushThresholdOption'])); + $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); @@ -1671,4 +1700,14 @@ private function validateLogFlushThresholdOption(?int $logFlushThreshold): bool { return $logFlushThreshold === null || $logFlushThreshold > 0; } + + /** + * Validates that the value passed to the "metric_flush_threshold" option is valid. + * + * @param int|null $metricFlushThreshold The value to validate + */ + private function validateMetricFlushThresholdOption(?int $metricFlushThreshold): bool + { + return $metricFlushThreshold === null || $metricFlushThreshold > 0; + } } diff --git a/src/functions.php b/src/functions.php index f8ae0be4fc..44f5b2d0ba 100644 --- a/src/functions.php +++ b/src/functions.php @@ -51,6 +51,7 @@ * integrations?: IntegrationInterface[]|callable(IntegrationInterface[]): IntegrationInterface[], * logger?: LoggerInterface|null, * log_flush_threshold?: int|null, + * metric_flush_threshold?: int|null, * max_breadcrumbs?: int, * max_request_body_size?: "none"|"never"|"small"|"medium"|"always", * max_value_length?: int, diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 0faab3a2fb..223e2ee5c1 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -73,15 +73,59 @@ public function testDistributionMetrics(): void $this->assertArrayHasKey('foo', $metric->getAttributes()->toSimpleArray()); } - public function testMetricsBufferFull(): void + public function testFlushesImmediatelyWhenMetricFlushThresholdIsReached(): void { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => 2, + ]), StubTransport::getInstance())); + + traceMetrics()->count('first-metric', 1, ['foo' => 'bar']); + + $this->assertCount(0, StubTransport::$events); + + traceMetrics()->count('second-metric', 2, ['foo' => 'bar']); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + + $this->assertCount(2, $event->getMetrics()); + $this->assertSame('first-metric', $event->getMetrics()[0]->getName()); + $this->assertSame('second-metric', $event->getMetrics()[1]->getName()); + } + + public function testDoesNotFlushImmediatelyWhenMetricFlushThresholdIsNull(): void + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => null, + ]), StubTransport::getInstance())); + + traceMetrics()->count('first-metric', 1, ['foo' => 'bar']); + traceMetrics()->count('second-metric', 2, ['foo' => 'bar']); + + $this->assertCount(0, StubTransport::$events); + + traceMetrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $this->assertCount(2, StubTransport::$events[0]->getMetrics()); + } + + public function testMetricsBufferFullWhenMetricFlushThresholdIsNull(): void + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => null, + ]), StubTransport::getInstance())); + for ($i = 0; $i < MetricsAggregator::METRICS_BUFFER_SIZE + 100; ++$i) { traceMetrics()->count('test', 1, ['foo' => 'bar']); } + traceMetrics()->flush(); + $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $metrics = $event->getMetrics(); + $this->assertCount(MetricsAggregator::METRICS_BUFFER_SIZE, $metrics); } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 9ae04194b3..4606978c65 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -114,6 +114,20 @@ public static function optionsDataProvider(): \Generator 'setLogFlushThreshold', ]; + yield [ + 'metric_flush_threshold', + 10, + 'getMetricFlushThreshold', + 'setMetricFlushThreshold', + ]; + + yield [ + 'metric_flush_threshold', + null, + 'getMetricFlushThreshold', + 'setMetricFlushThreshold', + ]; + yield [ 'traces_sample_rate', 0.5, @@ -682,6 +696,33 @@ public static function logFlushThresholdOptionIsValidatedCorrectlyDataProvider() ]; } + /** + * @dataProvider metricFlushThresholdOptionIsValidatedCorrectlyDataProvider + */ + public function testMetricFlushThresholdOptionIsValidatedCorrectly(bool $isValid, $value): void + { + if (!$isValid) { + $this->expectException(InvalidOptionsException::class); + } + + $options = new Options(['metric_flush_threshold' => $value]); + + $this->assertSame($value, $options->getMetricFlushThreshold()); + } + + public static function metricFlushThresholdOptionIsValidatedCorrectlyDataProvider(): array + { + return [ + [false, -1], + [false, 0], + [true, 1], + [true, 10], + [true, null], + [false, 'string'], + [false, '1'], + ]; + } + /** * @backupGlobals enabled */ From bbf4c73e7b9ffc94a4f163054db97e749cf0237e Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Thu, 23 Apr 2026 11:41:28 +0200 Subject: [PATCH 48/89] style: Add void return types to test methods (#2068) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Martin Linzmayer --- src/ErrorHandler.php | 2 +- src/State/HubAdapter.php | 2 +- tests/ClientBuilderTest.php | 2 +- tests/ExceptionDataBagTest.php | 2 +- tests/HttpClient/ResponseTest.php | 6 +++--- tests/Metrics/TraceMetricsTest.php | 4 ++-- tests/Monolog/LogsHandlerTest.php | 4 ++-- tests/OptionsTest.php | 2 +- tests/State/HubAdapterTest.php | 2 +- tests/Tracing/PropagationContextTest.php | 12 ++++++------ tests/Tracing/TransactionContextTest.php | 4 ++-- 11 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index 0839ce32b4..b17b7ee682 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -76,7 +76,7 @@ final class ErrorHandler /** * @var callable|null The previous exception handler, if any * - * @phpstan-var null|callable(\Throwable): void + * @phpstan-var (callable(\Throwable): void)|null */ private $previousExceptionHandler; diff --git a/src/State/HubAdapter.php b/src/State/HubAdapter.php index 503153860b..6571a62da1 100644 --- a/src/State/HubAdapter.php +++ b/src/State/HubAdapter.php @@ -206,7 +206,7 @@ public function __clone() /** * @see https://www.php.net/manual/en/language.oop5.magic.php#object.wakeup */ - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Unserializing instances of this class is forbidden.'); } diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index 24ee86a0d5..5a3c5316c7 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -23,7 +23,7 @@ final class ClientBuilderTest extends TestCase { - public function testGetOptions() + public function testGetOptions(): void { $options = new Options(); $clientBuilder = new ClientBuilder($options); diff --git a/tests/ExceptionDataBagTest.php b/tests/ExceptionDataBagTest.php index 57d6a8a55e..6508d95ecd 100644 --- a/tests/ExceptionDataBagTest.php +++ b/tests/ExceptionDataBagTest.php @@ -15,7 +15,7 @@ final class ExceptionDataBagTest extends TestCase /** * @dataProvider constructorDataProvider */ - public function testConstructor(array $constructorArgs, string $expectedType, string $expectedValue, ?Stacktrace $expectedStackTrace, ?ExceptionMechanism $expectedExceptionMechansim) + public function testConstructor(array $constructorArgs, string $expectedType, string $expectedValue, ?Stacktrace $expectedStackTrace, ?ExceptionMechanism $expectedExceptionMechansim): void { $exceptionDataBag = new ExceptionDataBag(...$constructorArgs); diff --git a/tests/HttpClient/ResponseTest.php b/tests/HttpClient/ResponseTest.php index 44f50d9fb5..6205255cec 100644 --- a/tests/HttpClient/ResponseTest.php +++ b/tests/HttpClient/ResponseTest.php @@ -9,7 +9,7 @@ final class ResponseTest extends TestCase { - public function testResponseSuccess() + public function testResponseSuccess(): void { $response = new Response( 200, @@ -32,7 +32,7 @@ public function testResponseSuccess() $this->assertFalse($response->hasError()); } - public function testResponseFailure() + public function testResponseFailure(): void { $response = new Response( 500, @@ -51,7 +51,7 @@ public function testResponseFailure() $this->assertTrue($response->hasError()); } - public function testResponseMultiValueHeader() + public function testResponseMultiValueHeader(): void { $response = new Response( 200, diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 223e2ee5c1..1191e076ec 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -141,7 +141,7 @@ public function testEnableMetrics(): void $this->assertEmpty(StubTransport::$events); } - public function testBeforeSendMetricAltersContent() + public function testBeforeSendMetricAltersContent(): void { HubAdapter::getInstance()->bindClient(new Client(new Options([ 'before_send_metric' => static function (Metric $metric) { @@ -162,7 +162,7 @@ public function testBeforeSendMetricAltersContent() $this->assertEquals(99999, $metric->getValue()); } - public function testIntType() + public function testIntType(): void { traceMetrics()->count('test-count', 2, ['foo' => 'bar']); traceMetrics()->flush(); diff --git a/tests/Monolog/LogsHandlerTest.php b/tests/Monolog/LogsHandlerTest.php index e77aa68099..b8039d716f 100644 --- a/tests/Monolog/LogsHandlerTest.php +++ b/tests/Monolog/LogsHandlerTest.php @@ -100,7 +100,7 @@ public function testFiltersAndMapsUsingMonologEnumThreshold($threshold, $recordL } } - public function testLogsHandlerDestructor() + public function testLogsHandlerDestructor(): void { $transport = new StubTransport(); $client = ClientBuilder::create([ @@ -135,7 +135,7 @@ public function testOriginTagAppliedWithHandler(): void $this->assertSame('auto.log.monolog', $log->attributes()->toSimpleArray()['sentry.origin']); } - public function testOriginTagNotAppliedWhenUsingDirectly() + public function testOriginTagNotAppliedWhenUsingDirectly(): void { \Sentry\logger()->info('No origin attribute'); diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 4606978c65..f788f7391f 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -587,7 +587,7 @@ public function excludedPathProviders(): array /** * @dataProvider includedPathProviders */ - public function testIncludedAppPathsOverrideExcludedAppPaths(string $value, string $expected) + public function testIncludedAppPathsOverrideExcludedAppPaths(string $value, string $expected): void { $configuration = new Options(['in_app_include' => [$value]]); diff --git a/tests/State/HubAdapterTest.php b/tests/State/HubAdapterTest.php index 5f4a6fbf4d..3e68b31647 100644 --- a/tests/State/HubAdapterTest.php +++ b/tests/State/HubAdapterTest.php @@ -270,7 +270,7 @@ public static function captureLastErrorDataProvider(): \Generator ]; } - public function testCaptureCheckIn() + public function testCaptureCheckIn(): void { $hub = new Hub(); diff --git a/tests/Tracing/PropagationContextTest.php b/tests/Tracing/PropagationContextTest.php index 2b6c4600b5..a63db80aea 100644 --- a/tests/Tracing/PropagationContextTest.php +++ b/tests/Tracing/PropagationContextTest.php @@ -14,7 +14,7 @@ final class PropagationContextTest extends TestCase { - public function testFromDefaults() + public function testFromDefaults(): void { $propagationContext = PropagationContext::fromDefaults(); @@ -27,7 +27,7 @@ public function testFromDefaults() /** * @dataProvider tracingDataProvider */ - public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen): void { $propagationContext = PropagationContext::fromHeaders($sentryTraceHeader, $baggageHeader); @@ -49,7 +49,7 @@ public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader /** * @dataProvider tracingDataProvider */ - public function testFromEnvironment(string $sentryTrace, string $baggage, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromEnvironment(string $sentryTrace, string $baggage, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen): void { $propagationContext = PropagationContext::fromEnvironment($sentryTrace, $baggage); @@ -95,7 +95,7 @@ public static function tracingDataProvider(): iterable ]; } - public function testToTraceparent() + public function testToTraceparent(): void { $propagationContext = PropagationContext::fromDefaults(); $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); @@ -104,7 +104,7 @@ public function testToTraceparent() $this->assertSame('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', $propagationContext->toTraceparent()); } - public function testToBaggage() + public function testToBaggage(): void { $dynamicSamplingContext = DynamicSamplingContext::fromHeader('sentry-trace_id=566e3688a61d4bc888951642d6f14a19'); $propagationContext = PropagationContext::fromDefaults(); @@ -113,7 +113,7 @@ public function testToBaggage() $this->assertSame('sentry-trace_id=566e3688a61d4bc888951642d6f14a19', $propagationContext->toBaggage()); } - public function testGetTraceContext() + public function testGetTraceContext(): void { $propagationContext = PropagationContext::fromDefaults(); $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); diff --git a/tests/Tracing/TransactionContextTest.php b/tests/Tracing/TransactionContextTest.php index 3f382d39fd..709bc60abb 100644 --- a/tests/Tracing/TransactionContextTest.php +++ b/tests/Tracing/TransactionContextTest.php @@ -37,7 +37,7 @@ public function testGettersAndSetters(): void /** * @dataProvider tracingDataProvider */ - public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen): void { $spanContext = TransactionContext::fromHeaders($sentryTraceHeader, $baggageHeader); @@ -51,7 +51,7 @@ public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader /** * @dataProvider tracingDataProvider */ - public function testFromEnvironment(string $sentryTrace, string $baggage, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromEnvironment(string $sentryTrace, string $baggage, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen): void { $spanContext = TransactionContext::fromEnvironment($sentryTrace, $baggage); From 417a98675b139d6c380f37d024752e1e301bb162 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:45:35 +0200 Subject: [PATCH 49/89] chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.25.2 to 2.25.4 (#2067) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Martin Linzmayer --- .github/workflows/changelog-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 43e2582947..362b20602a 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -14,5 +14,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@ba01e596c4a4c07692f0de10b0d4fe05f3dd0292 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@97d0c4286f32a80d09c8b89366d762fecc3e27b6 # v2 secrets: inherit From 6a18ce04b8f2fdfe705aad769d5778be0b769158 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:45:45 +0200 Subject: [PATCH 50/89] chore(deps): bump actions/cache from 5.0.4 to 5.0.5 (#2065) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Martin Linzmayer --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e877ac0e2..a3996341f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-${{ matrix.php.version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }} @@ -131,7 +131,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-runtime-frankenphp-composer-${{ hashFiles('**/composer.lock') }} @@ -196,7 +196,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-runtime-roadrunner-composer-${{ hashFiles('**/composer.lock') }} From e2d3263a1c7b6428aa7d4382c3e5a66dcae2ad55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:45:57 +0200 Subject: [PATCH 51/89] chore(deps): bump getsentry/craft from 2.25.2 to 2.25.4 (#2066) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Martin Linzmayer --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index e63324944d..d1c194fb59 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@ba01e596c4a4c07692f0de10b0d4fe05f3dd0292 + uses: getsentry/craft@97d0c4286f32a80d09c8b89366d762fecc3e27b6 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 5ad69f06b5f4cd24290cab17df406201ddfc5829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:46:05 +0200 Subject: [PATCH 52/89] chore(deps): bump actions/create-github-app-token from 3.0.0 to 3.1.1 (#2064) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Martin Linzmayer --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index d1c194fb59..433e65f88e 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -24,7 +24,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From bdba59a37c46418b4401470f7eb50e160bbc2e7f Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Apr 2026 14:26:56 +0200 Subject: [PATCH 53/89] feat(monolog): add handler to only capture exceptions (#2061) --- src/Monolog/ExceptionToSentryIssueHandler.php | 129 ++++++++++ src/Monolog/Handler.php | 3 +- .../ExceptionToSentryIssueHandlerTest.php | 230 ++++++++++++++++++ 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 src/Monolog/ExceptionToSentryIssueHandler.php create mode 100644 tests/Monolog/ExceptionToSentryIssueHandlerTest.php diff --git a/src/Monolog/ExceptionToSentryIssueHandler.php b/src/Monolog/ExceptionToSentryIssueHandler.php new file mode 100644 index 0000000000..b2e7abeb6b --- /dev/null +++ b/src/Monolog/ExceptionToSentryIssueHandler.php @@ -0,0 +1,129 @@ +|value-of|Level|LogLevel::* $level + */ + public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true) + { + $this->hub = $hub; + + parent::__construct($level, $bubble); + } + + /** + * @param array|LogRecord $record + */ + public function handle($record): bool + { + $exception = $this->getExceptionFromRecord($record); + + /** @phpstan-ignore-next-line */ + if ($exception === null || !$this->isHandling($record)) { + return false; + } + + $this->hub->withScope(function (Scope $scope) use ($record, $exception): void { + $scope->setExtra('monolog.channel', $record['channel']); + $scope->setExtra('monolog.level', $record['level_name']); + $scope->setExtra('monolog.message', $record['message']); + + $monologContextData = $this->getMonologContextData($this->getContextFromRecord($record)); + + if ($monologContextData !== []) { + $scope->setExtra('monolog.context', $monologContextData); + } + + $monologExtraData = $this->getExtraFromRecord($record); + + if ($monologExtraData !== []) { + $scope->setExtra('monolog.extra', $monologExtraData); + } + + $this->hub->captureException($exception); + }); + + return $this->bubble === false; + } + + /** + * @param array|LogRecord $record + */ + private function getExceptionFromRecord($record): ?\Throwable + { + $exception = $this->getContextFromRecord($record)['exception'] ?? null; + + if ($exception instanceof \Throwable) { + return $exception; + } + + return null; + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getContextFromRecord($record): array + { + return $this->getArrayFieldFromRecord($record, 'context'); + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getExtraFromRecord($record): array + { + return $this->getArrayFieldFromRecord($record, 'extra'); + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getArrayFieldFromRecord($record, string $field): array + { + if (isset($record[$field]) && \is_array($record[$field])) { + return $record[$field]; + } + + return []; + } + + /** + * @param array $context + * + * @return array + */ + private function getMonologContextData(array $context): array + { + unset($context['exception']); + + return $context; + } +} diff --git a/src/Monolog/Handler.php b/src/Monolog/Handler.php index 11fb16ae54..baa53c6460 100644 --- a/src/Monolog/Handler.php +++ b/src/Monolog/Handler.php @@ -17,7 +17,8 @@ * hub instance. * * @deprecated since version 4.24. To be removed in version 5.0. Use {@see LogsHandler} - * with the `enable_logs` SDK option instead. + * with the `enable_logs` SDK option instead for logging. {@see ExceptionToSentryIssueHandler} + * to send monolog exceptions to Sentry. * * @author Stefano Arlandini */ diff --git a/tests/Monolog/ExceptionToSentryIssueHandlerTest.php b/tests/Monolog/ExceptionToSentryIssueHandlerTest.php new file mode 100644 index 0000000000..20dd681397 --- /dev/null +++ b/tests/Monolog/ExceptionToSentryIssueHandlerTest.php @@ -0,0 +1,230 @@ + $record + * @param array $expectedExtra + */ + public function testHandleCapturesExceptionAndAddsMetadata($record, \Throwable $exception, array $expectedExtra): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with( + $this->identicalTo($exception), + $this->callback(function (Scope $scopeArg) use ($expectedExtra): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedExtra, $event->getExtra()); + + return true; + }), + null + ); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope())); + + $this->assertTrue($handler->isHandling($record)); + $handler->handle($record); + } + + public function testHandleReturnsFalseWhenBubblingEnabled(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with($this->identicalTo($exception), $this->isInstanceOf(Scope::class), null); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleReturnsTrueWhenBubblingDisabled(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with($this->identicalTo($exception), $this->isInstanceOf(Scope::class), null); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + /** + * @dataProvider ignoredRecordsDataProvider + * + * @param LogRecord|array $record + */ + public function testHandleIgnoresRecordsWithoutThrowable($record): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureException'); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleIgnoresRecordsBelowThreshold(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureException'); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::ERROR, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertFalse($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testLegacyIsHandlingUsesMinimalLevelRecord(): void + { + if (Logger::API >= 3) { + $this->markTestSkipped('Test only works for Monolog < 3'); + } + + $handler = new ExceptionToSentryIssueHandler(new Hub($this->createMock(ClientInterface::class), new Scope()), Logger::WARNING); + + $this->assertTrue($handler->isHandling(['level' => Logger::WARNING])); + $this->assertFalse($handler->isHandling(['level' => Logger::INFO])); + } + + /** + * @return iterable}> + */ + public static function ignoredRecordsDataProvider(): iterable + { + yield [ + RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []), + ]; + + yield [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => 'not an exception', + ], + [] + ), + ]; + } + + /** + * @return iterable, \Throwable, array}> + */ + public static function capturedRecordsDataProvider(): iterable + { + $exception = new \RuntimeException('exception message'); + + yield 'with exception only' => [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ), + $exception, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.message' => 'foo bar', + ], + ]; + + $exception = new \RuntimeException('exception message'); + + yield 'with context and extra' => [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + $exception, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.message' => 'foo bar', + 'monolog.context' => [ + 'foo' => 'bar', + ], + 'monolog.extra' => [ + 'bar' => 'baz', + ], + ], + ]; + } +} From 86dd794e0b3d97f4b9575c30310e7a069e0e5adf Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Apr 2026 14:34:09 +0200 Subject: [PATCH 54/89] fix(error): prevent warnings when trying to increase memory for OOM errors (#2063) --- src/ErrorHandler.php | 26 ++++++- ...warning_during_out_of_memory_handling.phpt | 73 +++++++++++++++++++ ...ncrease_during_out_of_memory_handling.phpt | 73 +++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 tests/phpt/error_handler_does_not_capture_memory_limit_increase_warning_during_out_of_memory_handling.phpt create mode 100644 tests/phpt/error_handler_skips_impossible_memory_limit_increase_during_out_of_memory_handling.phpt diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index b17b7ee682..3f3dd2d836 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -394,8 +394,15 @@ private function handleFatalError(): void && preg_match(self::OOM_MESSAGE_MATCHER, $error['message'], $matches) === 1 ) { $currentMemoryLimit = (int) $matches['memory_limit']; + $newMemoryLimit = $currentMemoryLimit + $this->memoryLimitIncreaseOnOutOfMemoryErrorValue; - ini_set('memory_limit', (string) ($currentMemoryLimit + $this->memoryLimitIncreaseOnOutOfMemoryErrorValue)); + // It can happen that the memory limit + increase is still lower than + // the memory that is currently being used. This produces warnings + // that may end up in Sentry. To prevent this, we can check the real + // usage before. + if ($newMemoryLimit > memory_get_usage(true)) { + $this->setMemoryLimitWithoutHandlingWarnings($newMemoryLimit); + } self::$didIncreaseMemoryLimit = true; } @@ -452,6 +459,23 @@ private function handleException(\Throwable $exception): void $this->handleException($previousExceptionHandlerException); } + /** + * Set the memory_limit while having no real error handler so that a warning emitted + * will not get reported. + */ + private function setMemoryLimitWithoutHandlingWarnings(int $memoryLimit): void + { + set_error_handler(static function (): bool { + return true; + }, \E_WARNING); + + try { + ini_set('memory_limit', (string) $memoryLimit); + } finally { + restore_error_handler(); + } + } + /** * Cleans and returns the backtrace without the first frames that belong to * this error handler. diff --git a/tests/phpt/error_handler_does_not_capture_memory_limit_increase_warning_during_out_of_memory_handling.phpt b/tests/phpt/error_handler_does_not_capture_memory_limit_increase_warning_during_out_of_memory_handling.phpt new file mode 100644 index 0000000000..7040842784 --- /dev/null +++ b/tests/phpt/error_handler_does_not_capture_memory_limit_increase_warning_during_out_of_memory_handling.phpt @@ -0,0 +1,73 @@ +--TEST-- +Test that OOM handling does not capture warnings from the memory limit increase attempt +--INI-- +memory_limit=67108864 +--FILE-- +addFatalErrorHandlerListener(static function (): void { + echo 'Fatal error listener called' . \PHP_EOL; + }); + + register_shutdown_function(static function (): void { + echo 'Memory limit increase attempts: ' . ($GLOBALS['sentry_test_ini_set_calls'] ?? 0) . \PHP_EOL; + echo 'Warning handler calls: ' . ($GLOBALS['sentry_test_warning_handler_calls'] ?? 0) . \PHP_EOL; + }); + + $foo = str_repeat('x', 1024 * 1024 * 1024); +} +?> +--EXPECTF-- +%A +Fatal error listener called +Memory limit increase attempts: 1 +Warning handler calls: 0 diff --git a/tests/phpt/error_handler_skips_impossible_memory_limit_increase_during_out_of_memory_handling.phpt b/tests/phpt/error_handler_skips_impossible_memory_limit_increase_during_out_of_memory_handling.phpt new file mode 100644 index 0000000000..7602b07c1d --- /dev/null +++ b/tests/phpt/error_handler_skips_impossible_memory_limit_increase_during_out_of_memory_handling.phpt @@ -0,0 +1,73 @@ +--TEST-- +Test that OOM handling skips the memory limit increase when current usage is already higher +--INI-- +memory_limit=67108864 +--FILE-- +addFatalErrorHandlerListener(static function (): void { + echo 'Fatal error listener called' . \PHP_EOL; + }); + + register_shutdown_function(static function (): void { + echo 'Memory limit increase attempts: ' . ($GLOBALS['sentry_test_ini_set_calls'] ?? 0) . \PHP_EOL; + echo 'Warning handler calls: ' . ($GLOBALS['sentry_test_warning_handler_calls'] ?? 0) . \PHP_EOL; + }); + + $foo = str_repeat('x', 1024 * 1024 * 1024); +} +?> +--EXPECTF-- +%A +Fatal error listener called +Memory limit increase attempts: 0 +Warning handler calls: 0 From d87ae3ee0086af6fa8554af3df15834918744a8d Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Apr 2026 14:49:25 +0200 Subject: [PATCH 55/89] Prepare 4.25.0 (#2070) --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f714ac2a..a721d08333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # CHANGELOG +## 4.25.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.25.0. + +### Features + +- Add `ExceptionToSentryIssueHandler` Monolog handler that only captures exceptions as Sentry issues without converting log messages to errors. [(#2061)](https://github.com/getsentry/sentry-php/pull/2061) +- Add `metric_flush_threshold` option to automatically flush buffered metrics after a configured number of metric records. [(#2059)](https://github.com/getsentry/sentry-php/pull/2059) + +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'metric_flush_threshold' => 50, +]); +``` + +### Bug Fixes + +- Prevent PHP warnings when trying to increase the memory limit for out-of-memory error handling. [(#2063)](https://github.com/getsentry/sentry-php/pull/2063) + +### Misc + +- Use a `RingBuffer` for log storage when `log_flush_threshold` is not set to prevent unbounded memory growth, with a hard cap of 1000 records. [(#2058)](https://github.com/getsentry/sentry-php/pull/2058) +- Add `ext-excimer` as a Composer suggestion to surface its requirement for profiling. [(#2057)](https://github.com/getsentry/sentry-php/pull/2057) + ## 4.24.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.24.0. From bfee3381e1f6dea8a5f3a18adba6419fe81c5f54 Mon Sep 17 00:00:00 2001 From: Litarnus <8436563+Litarnus@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:50:03 +0000 Subject: [PATCH 56/89] release: 4.25.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index fce324fc8d..2793c4830e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.24.0'; + public const SDK_VERSION = '4.25.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 010f212c150f5b8ff9403091f7bea2271ea09046 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Apr 2026 15:03:58 +0200 Subject: [PATCH 57/89] ref: add hint to excimer composer suggestion for PHP ZTS (#2071) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 121fa5817e..9b3c853956 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "spiral/roadrunner-worker": "^3.6" }, "suggest": { - "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension. (Please use 1.2.3 for FrankenPHP or PHP ZTS)", "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." }, "conflict": { From 4d9bebad7a35b42d9492c0c384e7447bcef655dd Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 27 Apr 2026 15:12:34 +0200 Subject: [PATCH 58/89] feat(agent): add AgentClient (#2062) --- src/Agent/Transport/AgentClient.php | 101 ++++++++++++ tests/HttpClient/AgentClientTest.php | 91 +++++++++++ tests/HttpClient/TestAgent.php | 233 +++++++++++++++++++++++++++ tests/HttpClient/TestServer.php | 6 +- tests/HttpClient/agent-server.php | 82 ++++++++++ 5 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 src/Agent/Transport/AgentClient.php create mode 100644 tests/HttpClient/AgentClientTest.php create mode 100644 tests/HttpClient/TestAgent.php create mode 100644 tests/HttpClient/agent-server.php diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php new file mode 100644 index 0000000000..a97047e1cb --- /dev/null +++ b/src/Agent/Transport/AgentClient.php @@ -0,0 +1,101 @@ +host = $host; + $this->port = $port; + } + + public function __destruct() + { + $this->disconnect(); + } + + /** + * @phpstan-assert-if-true resource $this->socket + */ + private function connect(): bool + { + if ($this->socket !== null) { + return true; + } + + // We set the timeout to 10ms to avoid blocking the request for too long if the agent is not running + // @TODO: 10ms should be low enough? Do we want to go lower and/or make this configurable? Only applies to initial connection. + $socket = fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01); + + // @TODO: Error handling? See $errorNo and $errorMsg + if ($socket === false) { + return false; + } + + // @TODO: Set a timeout for the socket to prevent blocking (?) if the socket connection stops working after the connection (e.g. the agent is stopped) if needed + $this->socket = $socket; + + return true; + } + + private function disconnect(): void + { + if ($this->socket === null) { + return; + } + + fclose($this->socket); + + $this->socket = null; + } + + private function send(string $message): void + { + if (!$this->connect()) { + return; + } + + // @TODO: Make sure we don't send more than 2^32 - 1 bytes + $contentLength = pack('N', \strlen($message) + 4); + + // @TODO: Error handling? + fwrite($this->socket, $contentLength . $message); + } + + public function sendRequest(Request $request, Options $options): Response + { + $body = $request->getStringBody(); + + if (empty($body)) { + return new Response(400, [], 'Request body is empty'); + } + + $this->send($body); + + // Since we are sending async there is no feedback so we always return an empty response + return new Response(202, [], ''); + } +} diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php new file mode 100644 index 0000000000..1887c05446 --- /dev/null +++ b/tests/HttpClient/AgentClientTest.php @@ -0,0 +1,91 @@ +agentProcess !== null) { + $this->stopTestAgent(); + } + } + + public function testClientHandsOffEnvelopeToLocalAgent(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from agent client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $client = new AgentClient('127.0.0.1', $this->agentPort); + $response = $client->sendRequest($request, new Options()); + + $this->waitForEnvelopeCount(1); + $agentOutput = $this->stopTestAgent(); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertCount(1, $agentOutput['messages']); + $this->assertStringContainsString('Hello from agent client test!', $agentOutput['messages'][0]); + $this->assertStringContainsString('"type":"event"', $agentOutput['messages'][0]); + } + + public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from unavailable agent test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $client = new AgentClient('127.0.0.1', 65001); + + set_error_handler(static function (): bool { + return true; + }); + + try { + $response = $client->sendRequest($request, new Options()); + } finally { + restore_error_handler(); + } + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + } + + public function testClientReturnsErrorWhenBodyIsEmpty(): void + { + $client = new AgentClient(); + $response = $client->sendRequest(new Request(), new Options()); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Request body is empty', $response->getError()); + } + + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } +} diff --git a/tests/HttpClient/TestAgent.php b/tests/HttpClient/TestAgent.php new file mode 100644 index 0000000000..9e29063c47 --- /dev/null +++ b/tests/HttpClient/TestAgent.php @@ -0,0 +1,233 @@ +startTestAgent()` to start the agent. + * After you are done, call `$this->stopTestAgent()` to stop the agent and get + * the captured envelopes. + */ +trait TestAgent +{ + /** + * @var resource|null the agent process handle + */ + protected $agentProcess; + + /** + * @var resource|null the agent stderr handle + */ + protected $agentStderr; + + /** + * @var string|null the path to the output file + */ + protected $agentOutputFile; + + /** + * @var int the port on which the agent is listening, this default value was randomly chosen + */ + protected $agentPort = 45848; + + /** + * Start the test agent. + * + * @return string the address the agent is listening on + */ + public function startTestAgent(): string + { + if ($this->agentProcess !== null) { + throw new \RuntimeException('There is already a test agent instance running.'); + } + + $outputFile = tempnam(sys_get_temp_dir(), 'sentry-agent-client-output-'); + + if ($outputFile === false) { + throw new \RuntimeException('Failed to create the output file for the test agent.'); + } + + $this->agentOutputFile = $outputFile; + + $pipes = []; + + $this->agentProcess = proc_open( + $command = \sprintf( + 'php %s %d %s', + escapeshellarg((string) realpath(__DIR__ . '/agent-server.php')), + $this->agentPort, + escapeshellarg($this->agentOutputFile) + ), + [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ], + $pipes + ); + + $this->agentStderr = $pipes[2]; + + $pid = proc_get_status($this->agentProcess)['pid']; + + if (!\is_resource($this->agentProcess)) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + $address = "127.0.0.1:{$this->agentPort}"; + + // Wait for the agent to be ready to accept connections + $startTime = microtime(true); + $timeout = 5; // 5 seconds timeout + + while (true) { + $socket = @stream_socket_client("tcp://{$address}", $errno, $errstr, 1); + + if ($socket !== false) { + fclose($socket); + break; + } + + if (microtime(true) - $startTime > $timeout) { + $this->stopTestAgent(); + throw new \RuntimeException("Timeout waiting for test agent to start on {$address}"); + } + + usleep(10000); + } + + // Ensure the process is still running + if (!proc_get_status($this->agentProcess)['running']) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + return $address; + } + + /** + * Wait for the test agent to receive the expected number of envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function waitForEnvelopeCount(int $expectedCount, float $timeout = 5.0): array + { + if ($this->agentProcess === null) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $startTime = microtime(true); + + while (true) { + $output = $this->readAgentOutput(); + + if (\count($output['messages']) >= $expectedCount) { + return $output; + } + + if (microtime(true) - $startTime > $timeout) { + throw new \RuntimeException(\sprintf('Timeout waiting for %d envelope(s), got %d.', $expectedCount, \count($output['messages']))); + } + + usleep(10000); + } + } + + /** + * Stop the test agent and return the captured envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function stopTestAgent(): array + { + if (!$this->agentProcess) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $output = $this->readAgentOutput(); + + for ($i = 0; $i < 20; ++$i) { + $status = proc_get_status($this->agentProcess); + + if (!$status['running']) { + break; + } + + $this->killAgentProcess($status['pid']); + + usleep(10000); + } + + if ($status['running']) { + throw new \RuntimeException('Could not kill test agent'); + } + + proc_close($this->agentProcess); + + if ($this->agentOutputFile !== null && file_exists($this->agentOutputFile)) { + unlink($this->agentOutputFile); + } + + $this->agentProcess = null; + $this->agentStderr = null; + $this->agentOutputFile = null; + + return $output; + } + + /** + * @return array{ + * messages: string[], + * connections: int, + * } + */ + private function readAgentOutput(): array + { + if ($this->agentOutputFile === null || !file_exists($this->agentOutputFile)) { + return ['messages' => [], 'connections' => 0]; + } + + $output = file_get_contents($this->agentOutputFile); + + if ($output === false || $output === '') { + return ['messages' => [], 'connections' => 0]; + } + + $decoded = json_decode($output, true); + + if (!\is_array($decoded)) { + return ['messages' => [], 'connections' => 0]; + } + + return [ + 'messages' => $decoded['messages'] ?? [], + 'connections' => $decoded['connections'] ?? 0, + ]; + } + + private function killAgentProcess(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec("taskkill /pid {$pid} /f /t"); + } else { + // Kills any child processes + exec("pkill -P {$pid}"); + + // Kill the parent process + exec("kill {$pid}"); + } + + proc_terminate($this->agentProcess, 9); + } +} diff --git a/tests/HttpClient/TestServer.php b/tests/HttpClient/TestServer.php index d915187ef2..8b4a1593ac 100644 --- a/tests/HttpClient/TestServer.php +++ b/tests/HttpClient/TestServer.php @@ -34,7 +34,7 @@ trait TestServer /** * @var int the port on which the server is listening, this default value was randomly chosen */ - protected $serverPort = 44884; + protected $serverPort = 45884; public function startTestServer(): string { @@ -50,9 +50,9 @@ public function startTestServer(): string $this->serverProcess = proc_open( $command = \sprintf( - 'php -S localhost:%d -t %s', + 'php -S localhost:%d %s', $this->serverPort, - realpath(__DIR__ . '/../testserver') + realpath(__DIR__ . '/../testserver/index.php') ), [2 => ['pipe', 'w']], $pipes diff --git a/tests/HttpClient/agent-server.php b/tests/HttpClient/agent-server.php new file mode 100644 index 0000000000..d81c981b5c --- /dev/null +++ b/tests/HttpClient/agent-server.php @@ -0,0 +1,82 @@ + \n"); + + exit(1); +} + +$port = (int) $argv[1]; +$outputFile = $argv[2]; + +$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errorNo, $errorMessage); + +if ($server === false) { + fwrite(\STDERR, sprintf("Failed to start test agent server: [%d] %s\n", $errorNo, $errorMessage)); + + exit(1); +} + +$messages = []; +$connections = 0; + +$writeOutput = static function () use (&$messages, &$connections, $outputFile): void { + file_put_contents($outputFile, json_encode([ + 'messages' => $messages, + 'connections' => $connections, + ])); +}; + +$writeOutput(); + +while ($connection = @stream_socket_accept($server, -1)) { + ++$connections; + $writeOutput(); + + $buffer = ''; + $messageLength = 0; + + while (!feof($connection)) { + $chunk = fread($connection, 8192); + + if ($chunk === false) { + break; + } + + if ($chunk === '') { + continue; + } + + $buffer .= $chunk; + + while (strlen($buffer) >= 4) { + if ($messageLength === 0) { + $unpackedHeader = unpack('N', substr($buffer, 0, 4)); + + if ($unpackedHeader === false) { + break 2; + } + + $messageLength = $unpackedHeader[1]; + } + + if (strlen($buffer) < $messageLength) { + break; + } + + $messages[] = substr($buffer, 4, $messageLength - 4); + $buffer = (string) substr($buffer, $messageLength); + $messageLength = 0; + + $writeOutput(); + } + } + + fclose($connection); +} From 479af8a40a6ec34118aa1db6827e10a19a9c00b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:05:30 +0200 Subject: [PATCH 59/89] chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.25.4 to 2.26.2 (#2074) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/changelog-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 362b20602a..97f0a86617 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -14,5 +14,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@97d0c4286f32a80d09c8b89366d762fecc3e27b6 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2 secrets: inherit From 6f19d11bccd3ec68132e99b2ad01418f67a68b18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:05:45 +0200 Subject: [PATCH 60/89] chore(deps): bump getsentry/craft from 2.25.4 to 2.26.2 (#2073) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 433e65f88e..1b27a8b836 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@97d0c4286f32a80d09c8b89366d762fecc3e27b6 + uses: getsentry/craft@3dc647fee3586e57c7c31eb900fdec7cbb44f23f env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From be76fb7fc5a92a96c3ab6558fd91fc1ed809bc77 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 28 Apr 2026 14:23:59 +0200 Subject: [PATCH 61/89] Fallback agent client (#2072) --- src/Agent/Transport/AgentClient.php | 208 +++++++++++++-- src/Agent/Transport/AgentClientBuilder.php | 127 ++++++++++ tests/HttpClient/AgentClientBuilderTest.php | 171 +++++++++++++ tests/HttpClient/AgentClientTest.php | 264 +++++++++++++++++++- 4 files changed, 743 insertions(+), 27 deletions(-) create mode 100644 src/Agent/Transport/AgentClientBuilder.php create mode 100644 tests/HttpClient/AgentClientBuilderTest.php diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php index a97047e1cb..fdb17aba9e 100644 --- a/src/Agent/Transport/AgentClient.php +++ b/src/Agent/Transport/AgentClient.php @@ -11,6 +11,8 @@ class AgentClient implements HttpClientInterface { + private const SOCKET_TIMEOUT_SECONDS = 0.01; + /** * @var string */ @@ -26,10 +28,34 @@ class AgentClient implements HttpClientInterface */ private $socket; - public function __construct(string $host = '127.0.0.1', int $port = 5148) + /** + * @var HttpClientInterface|null + */ + private $fallbackClient; + + /** + * @var (callable(): HttpClientInterface)|null + */ + private $fallbackClientFactory; + + /** + * @var string|null + */ + private $fallbackClientError; + + /** + * @var string + */ + private $lastSendError = ''; + + /** + * @phpstan-param (callable(): HttpClientInterface)|null $fallbackClientFactory + */ + public function __construct(string $host = '127.0.0.1', int $port = 5148, ?callable $fallbackClientFactory = null) { $this->host = $host; $this->port = $port; + $this->fallbackClientFactory = $fallbackClientFactory; } public function __destruct() @@ -46,16 +72,26 @@ private function connect(): bool return true; } - // We set the timeout to 10ms to avoid blocking the request for too long if the agent is not running - // @TODO: 10ms should be low enough? Do we want to go lower and/or make this configurable? Only applies to initial connection. - $socket = fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01); + // 10ms connect timeout to avoid blocking the request if the agent is not running + $errorNo = 0; + $errorMsg = ''; + $socket = @fsockopen($this->host, $this->port, $errorNo, $errorMsg, self::SOCKET_TIMEOUT_SECONDS); - // @TODO: Error handling? See $errorNo and $errorMsg if ($socket === false) { + $this->lastSendError = \sprintf( + 'Failed to connect to the local Sentry agent at %s:%d. [%d] %s', + $this->host, + $this->port, + $errorNo, + $errorMsg + ); + return false; } - // @TODO: Set a timeout for the socket to prevent blocking (?) if the socket connection stops working after the connection (e.g. the agent is stopped) if needed + // Use non-blocking writes with stream_select() so a hung agent cannot block the caller indefinitely. + stream_set_blocking($socket, false); + $this->socket = $socket; return true; @@ -72,17 +108,120 @@ private function disconnect(): void $this->socket = null; } - private function send(string $message): void + private function send(string $message): bool { - if (!$this->connect()) { - return; + $this->lastSendError = ''; + + $payload = pack('N', \strlen($message) + 4) . $message; + + // Attempt to send the payload, retrying once on write failure to handle + // stale sockets (e.g. agent restarts in long-running workers). + for ($attempt = 0; $attempt < 2; ++$attempt) { + if (!$this->connect()) { + return false; + } + + if ($this->writePayload($payload)) { + return true; + } + + $this->disconnect(); + } + + $this->lastSendError = \sprintf( + 'Failed to write envelope to the local Sentry agent at %s:%d.', + $this->host, + $this->port + ); + + return false; + } + + private function writePayload(string $payload): bool + { + if ($this->socket === null) { + return false; + } + + $socket = $this->socket; + $payloadLength = \strlen($payload); + $totalWrittenBytes = 0; + $writeDeadline = microtime(true) + self::SOCKET_TIMEOUT_SECONDS; + + while ($totalWrittenBytes < $payloadLength) { + if (!$this->waitUntilSocketIsWritable($socket, $writeDeadline)) { + return false; + } + + $bytesWritten = @fwrite($socket, (string) substr($payload, $totalWrittenBytes)); + + if ($bytesWritten === false) { + return false; + } + + $totalWrittenBytes += $bytesWritten; + } + + return true; + } + + /** + * @param resource $socket + */ + private function waitUntilSocketIsWritable($socket, float $deadline): bool + { + $remainingSeconds = $deadline - microtime(true); + + if ($remainingSeconds <= 0) { + return false; + } + + $readSockets = null; + $writeSockets = [$socket]; + $exceptSockets = null; + $selectedSockets = @stream_select( + $readSockets, + $writeSockets, + $exceptSockets, + 0, + (int) ceil($remainingSeconds * 1000000) + ); + + return $selectedSockets !== false && $selectedSockets > 0; + } + + private function getFallbackClient(): ?HttpClientInterface + { + if ($this->fallbackClient !== null) { + return $this->fallbackClient; + } + + if ($this->fallbackClientFactory === null) { + return null; } - // @TODO: Make sure we don't send more than 2^32 - 1 bytes - $contentLength = pack('N', \strlen($message) + 4); + try { + $fallbackClient = ($this->fallbackClientFactory)(); + } catch (\Throwable $exception) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = \sprintf( + 'Failed to initialize fallback HTTP client. Reason: "%s". Fallback delivery has been disabled.', + $exception->getMessage() + ); + + return null; + } - // @TODO: Error handling? - fwrite($this->socket, $contentLength . $message); + if (!$fallbackClient instanceof HttpClientInterface) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = 'The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.'; + + return null; + } + + $this->fallbackClient = $fallbackClient; + + return $this->fallbackClient; } public function sendRequest(Request $request, Options $options): Response @@ -93,9 +232,46 @@ public function sendRequest(Request $request, Options $options): Response return new Response(400, [], 'Request body is empty'); } - $this->send($body); + if ($this->send($body)) { + // Since we are sending async there is no feedback so we always return an empty response + return new Response(202, [], ''); + } + + $logContext = [ + 'agent_host' => $this->host, + 'agent_port' => $this->port, + ]; + + if ($this->lastSendError !== '') { + $logContext['error'] = $this->lastSendError; + } + + $options->getLoggerOrNullLogger()->debug('Failed to hand off envelope to local Sentry agent.', $logContext); + + $fallbackClient = $this->getFallbackClient(); + if ($fallbackClient !== null) { + $options->getLoggerOrNullLogger()->debug('Using fallback HTTP client because local Sentry agent handoff failed.', $logContext); + + try { + return $fallbackClient->sendRequest($request, $options); + } catch (\Throwable $exception) { + $options->getLoggerOrNullLogger()->debug( + 'Fallback HTTP client failed while sending envelope.', + array_merge($logContext, ['exception' => $exception]) + ); + + return new Response(502, [], \sprintf( + 'Failed to send envelope using fallback HTTP client. Reason: "%s".', + $exception->getMessage() + )); + } + } + + if ($this->fallbackClientError !== null) { + $options->getLoggerOrNullLogger()->debug($this->fallbackClientError, $logContext); + $this->fallbackClientError = null; + } - // Since we are sending async there is no feedback so we always return an empty response - return new Response(202, [], ''); + return new Response(502, [], 'Failed to send envelope to the local Sentry agent and no fallback client is available.'); } } diff --git a/src/Agent/Transport/AgentClientBuilder.php b/src/Agent/Transport/AgentClientBuilder.php new file mode 100644 index 0000000000..dcdd6bb2ae --- /dev/null +++ b/src/Agent/Transport/AgentClientBuilder.php @@ -0,0 +1,127 @@ +host = $host; + + return $this; + } + + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + public function disableFallbackClient(): self + { + $this->isFallbackClientDisabled = true; + $this->fallbackClientFactory = null; + + return $this; + } + + public function setFallbackClient(HttpClientInterface $fallbackClient): self + { + return $this->setFallbackClientFactory(static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + } + + /** + * @phpstan-param callable(): HttpClientInterface $fallbackClientFactory + */ + public function setFallbackClientFactory(callable $fallbackClientFactory): self + { + $this->isFallbackClientDisabled = false; + $this->fallbackClientFactory = $fallbackClientFactory; + + return $this; + } + + public function setSdkIdentifier(string $sdkIdentifier): self + { + $this->sdkIdentifier = $sdkIdentifier; + + return $this; + } + + public function setSdkVersion(string $sdkVersion): self + { + $this->sdkVersion = $sdkVersion; + + return $this; + } + + public function getClient(): AgentClient + { + if ($this->isFallbackClientDisabled) { + return new AgentClient($this->host, $this->port, null); + } + + if ($this->fallbackClientFactory !== null) { + return new AgentClient($this->host, $this->port, $this->fallbackClientFactory); + } + + return new AgentClient($this->host, $this->port, $this->createDefaultFallbackClientFactory()); + } + + /** + * @return callable(): HttpClientInterface + */ + private function createDefaultFallbackClientFactory(): callable + { + $sdkIdentifier = $this->sdkIdentifier; + $sdkVersion = $this->sdkVersion; + + return static function () use ($sdkIdentifier, $sdkVersion): HttpClientInterface { + return new HttpClient($sdkIdentifier, $sdkVersion); + }; + } +} diff --git a/tests/HttpClient/AgentClientBuilderTest.php b/tests/HttpClient/AgentClientBuilderTest.php new file mode 100644 index 0000000000..ffcd72f0ec --- /dev/null +++ b/tests/HttpClient/AgentClientBuilderTest.php @@ -0,0 +1,171 @@ +serverProcess !== null) { + $this->stopTestServer(); + } + + StubLogger::$logs = []; + } + + public function testBuilderUsesFallbackClientByDefaultWhenLocalAgentIsUnavailable(): void + { + $testServer = $this->startTestServer(); + $dsn = "http://publicKey@{$testServer}/200"; + + $envelope = $this->createEnvelope($dsn, 'Hello from builder default fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => $dsn]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $serverOutput = $this->stopTestServer(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertStringContainsString('Hello from builder default fallback test!', $serverOutput['body']); + } + + public function testBuilderCanDisableFallbackClient(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from disabled fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->disableFallbackClient() + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertFalse($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testBuilderUsesCustomFallbackClientWhenConfigured(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from custom fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(201, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setFallbackClient($fallbackClient) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + } + + public function testBuilderCreatesDefaultFallbackClientWithConfiguredSdkIdentifierAndVersion(): void + { + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setSdkIdentifier('sentry.test') + ->setSdkVersion('1.2.3-test') + ->getClient(); + + $fallbackClientFactoryProperty = new \ReflectionProperty($client, 'fallbackClientFactory'); + if (\PHP_VERSION_ID < 80100) { + $fallbackClientFactoryProperty->setAccessible(true); + } + + /** @var mixed $fallbackClientFactory */ + $fallbackClientFactory = $fallbackClientFactoryProperty->getValue($client); + + $this->assertIsCallable($fallbackClientFactory); + $fallbackClient = $fallbackClientFactory(); + $this->assertInstanceOf(HttpClient::class, $fallbackClient); + + $sdkIdentifierProperty = new \ReflectionProperty($fallbackClient, 'sdkIdentifier'); + $sdkVersionProperty = new \ReflectionProperty($fallbackClient, 'sdkVersion'); + if (\PHP_VERSION_ID < 80100) { + $sdkIdentifierProperty->setAccessible(true); + $sdkVersionProperty->setAccessible(true); + } + + $this->assertSame('sentry.test', $sdkIdentifierProperty->getValue($fallbackClient)); + $this->assertSame('1.2.3-test', $sdkVersionProperty->getValue($fallbackClient)); + } + + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } +} diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php index 1887c05446..4d1db39fc8 100644 --- a/tests/HttpClient/AgentClientTest.php +++ b/tests/HttpClient/AgentClientTest.php @@ -7,19 +7,35 @@ use PHPUnit\Framework\TestCase; use Sentry\Agent\Transport\AgentClient; use Sentry\Event; +use Sentry\HttpClient\HttpClientInterface; use Sentry\HttpClient\Request; +use Sentry\HttpClient\Response; use Sentry\Options; use Sentry\Serializer\PayloadSerializer; +use Sentry\Tests\StubLogger; final class AgentClientTest extends TestCase { use TestAgent; + // Reserved address TEST-NET-1, which should not be bound for anything + private const UNAVAILABLE_AGENT_HOST = '192.0.2.1'; + private const UNAVAILABLE_AGENT_PORT = 5148; + + protected function setUp(): void + { + parent::setUp(); + + StubLogger::$logs = []; + } + protected function tearDown(): void { if ($this->agentProcess !== null) { $this->stopTestAgent(); } + + StubLogger::$logs = []; } public function testClientHandsOffEnvelopeToLocalAgent(): void @@ -31,7 +47,14 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $request = new Request(); $request->setStringBody($envelope); - $client = new AgentClient('127.0.0.1', $this->agentPort); + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient('127.0.0.1', $this->agentPort, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); $response = $client->sendRequest($request, new Options()); $this->waitForEnvelopeCount(1); @@ -44,27 +67,149 @@ public function testClientHandsOffEnvelopeToLocalAgent(): void $this->assertStringContainsString('"type":"event"', $agentOutput['messages'][0]); } - public function testClientReturnsAcceptedWhenLocalAgentIsUnavailable(): void + public function testClientReturnsErrorAndLogsDebugWhenLocalAgentIsUnavailableWithoutFallback(): void { $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from unavailable agent test!'); $request = new Request(); $request->setStringBody($envelope); - $client = new AgentClient('127.0.0.1', 65001); + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, null); + $response = $client->sendRequest($request, $options); - set_error_handler(static function (): bool { - return true; - }); + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + } - try { - $response = $client->sendRequest($request, new Options()); - } finally { - restore_error_handler(); - } + public function testClientLazilyInitializesFallbackFactoryOnlyWhenNeeded(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from lazy fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient( + '127.0.0.1', + $this->agentPort, + static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + } + ); + $response = $client->sendRequest($request, new Options()); + + $this->waitForEnvelopeCount(1); + $this->stopTestAgent(); $this->assertSame(202, $response->getStatusCode()); $this->assertSame('', $response->getError()); + $this->assertSame(0, $factoryCallCount); + } + + public function testClientUsesFallbackClientWhenLocalAgentIsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options([ + 'dsn' => 'http://public@example.com/1', + 'logger' => $logger, + ]); + + $fallbackResponse = new Response(200, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertTrue($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testClientReusesFallbackClientWhenLocalAgentRemainsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from cached fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(200, [], ''); + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->exactly(2)) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + }); + + $firstResponse = $client->sendRequest($request, $options); + $secondResponse = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $firstResponse); + $this->assertSame($fallbackResponse, $secondResponse); + $this->assertSame(1, $factoryCallCount); + } + + public function testClientDoesNotThrowWhenFallbackClientThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willThrowException(new \RuntimeException('fallback boom')); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope using fallback HTTP client. Reason: "fallback boom".', $response->getError()); + $this->assertTrue($this->hasLogMessage('Fallback HTTP client failed while sending envelope.')); } public function testClientReturnsErrorWhenBodyIsEmpty(): void @@ -77,6 +222,78 @@ public function testClientReturnsErrorWhenBodyIsEmpty(): void $this->assertSame('Request body is empty', $response->getError()); } + public function testClientDoesNotThrowWhenFallbackFactoryThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessageContaining('Failed to initialize fallback HTTP client.')); + } + + public function testClientLogsFallbackFactoryErrorOnlyOnce(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from repeated throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + + $client->sendRequest($request, $options); + $client->sendRequest($request, $options); + + $this->assertSame(1, $this->countLogMessagesContaining('Failed to initialize fallback HTTP client.')); + } + + public function testClientDoesNotThrowWhenFallbackFactoryReturnsUnexpectedValue(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from invalid fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function () { + return new \stdClass(); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessage('The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.')); + } + private function createEnvelope(string $dsn, string $message): string { $options = new Options(['dsn' => $dsn]); @@ -88,4 +305,29 @@ private function createEnvelope(string $dsn, string $message): string return $serializer->serialize($event); } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } + + private function countLogMessagesContaining(string $message): int + { + $result = array_filter(StubLogger::$logs, static function (array $log) use ($message): bool { + return strpos($log['message'], $message) !== false; + }); + + return \count($result); + } + + private function hasLogMessageContaining(string $message): bool + { + return $this->countLogMessagesContaining($message) > 0; + } } From 89fdff02fb6ff00eeef79f332222ce4205d749b5 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 28 Apr 2026 14:44:37 +0200 Subject: [PATCH 62/89] fix(logs): guard pii in logs (#2076) --- src/Logs/LogsAggregator.php | 28 ++++++++++++++------------- tests/Logs/LogsAggregatorTest.php | 32 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index bc0b5f302a..29571175f7 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -90,20 +90,22 @@ public function add( $log->setAttribute('sentry.sdk.version', $client->getSdkVersion()); } - $hub->configureScope(static function (Scope $scope) use ($log) { - $user = $scope->getUser(); - if ($user !== null) { - if ($user->getId() !== null) { - $log->setAttribute('user.id', $user->getId()); + if ($options->shouldSendDefaultPii()) { + $hub->configureScope(static function (Scope $scope) use ($log) { + $user = $scope->getUser(); + if ($user !== null) { + if ($user->getId() !== null) { + $log->setAttribute('user.id', $user->getId()); + } + if ($user->getEmail() !== null) { + $log->setAttribute('user.email', $user->getEmail()); + } + if ($user->getUsername() !== null) { + $log->setAttribute('user.name', $user->getUsername()); + } } - if ($user->getEmail() !== null) { - $log->setAttribute('user.email', $user->getEmail()); - } - if ($user->getUsername() !== null) { - $log->setAttribute('user.name', $user->getUsername()); - } - } - }); + }); + } if (\count($values)) { $log->setAttribute('sentry.message.template', $message); diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index 2082034320..eb701f5be5 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -164,6 +164,7 @@ public function testAttributesAreAddedToLogMessage(): void { $client = ClientBuilder::create([ 'enable_logs' => true, + 'send_default_pii' => true, 'release' => '1.0.0', 'environment' => 'production', 'server_name' => 'web-server-01', @@ -210,6 +211,37 @@ public function testAttributesAreAddedToLogMessage(): void $this->assertSame('my_user', $attributes->get('user.name')->getValue()); } + public function testUserAttributesAreNotAddedToLogMessageWhenSendDefaultPiiIsDisabled(): void + { + $client = ClientBuilder::create([ + 'enable_logs' => true, + 'send_default_pii' => false, + ])->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $hub->configureScope(static function (Scope $scope) { + $userDataBag = new UserDataBag(); + $userDataBag->setId('unique_id'); + $userDataBag->setEmail('foo@example.com'); + $userDataBag->setUsername('my_user'); + $scope->setUser($userDataBag); + }); + + $aggregator = new LogsAggregator(); + $aggregator->add(LogLevel::info(), 'User performed action'); + + $logs = $aggregator->all(); + $this->assertCount(1, $logs); + + $attributes = $logs[0]->attributes(); + + $this->assertNull($attributes->get('user.id')); + $this->assertNull($attributes->get('user.email')); + $this->assertNull($attributes->get('user.name')); + } + public function testFlushesImmediatelyWhenThresholdIsReached(): void { StubTransport::$events = []; From 7098cf6fd7016a1c417e0ac8c5b94fd2bd5cdd9d Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 29 Apr 2026 14:02:35 +0200 Subject: [PATCH 63/89] feat: add handler to convert logs to sentry issues (#2075) --- src/Monolog/Handler.php | 5 +- src/Monolog/LogToSentryIssueHandler.php | 118 +++++++ src/Monolog/LogsHandler.php | 2 +- tests/Monolog/LogToSentryIssueHandlerTest.php | 295 ++++++++++++++++++ 4 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 src/Monolog/LogToSentryIssueHandler.php create mode 100644 tests/Monolog/LogToSentryIssueHandlerTest.php diff --git a/src/Monolog/Handler.php b/src/Monolog/Handler.php index baa53c6460..6cc69fef7a 100644 --- a/src/Monolog/Handler.php +++ b/src/Monolog/Handler.php @@ -17,8 +17,9 @@ * hub instance. * * @deprecated since version 4.24. To be removed in version 5.0. Use {@see LogsHandler} - * with the `enable_logs` SDK option instead for logging. {@see ExceptionToSentryIssueHandler} - * to send monolog exceptions to Sentry. + * with the `enable_logs` SDK option for Sentry logs, {@see ExceptionToSentryIssueHandler} + * to send Monolog exceptions to Sentry issues, and {@see LogToSentryIssueHandler} + * to send Monolog log messages to Sentry issues. * * @author Stefano Arlandini */ diff --git a/src/Monolog/LogToSentryIssueHandler.php b/src/Monolog/LogToSentryIssueHandler.php new file mode 100644 index 0000000000..18dd6eab61 --- /dev/null +++ b/src/Monolog/LogToSentryIssueHandler.php @@ -0,0 +1,118 @@ +|value-of|Level|LogLevel::* $level + */ + public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true, bool $fillExtraContext = false) + { + $this->hub = $hub; + $this->fillExtraContext = $fillExtraContext; + + parent::__construct($level, $bubble); + } + + /** + * @param array|LogRecord $record + */ + public function handle($record): bool + { + /** @phpstan-ignore-next-line */ + if (!$this->isHandling($record) || $this->hasThrowable($record)) { + return false; + } + + /** @phpstan-ignore-next-line */ + return parent::handle($record); + } + + /** + * @param array|LogRecord $record + */ + protected function doWrite($record): void + { + $event = Event::createEvent(); + $event->setLevel(self::getSeverityFromLevel($record['level'])); + $event->setMessage($record['message']); + $event->setLogger(\sprintf('monolog.%s', $record['channel'])); + + $hint = new EventHint(); + + $this->hub->withScope(function (Scope $scope) use ($record, $event, $hint): void { + $scope->setExtra('monolog.channel', $record['channel']); + $scope->setExtra('monolog.level', $record['level_name']); + + if ($this->fillExtraContext) { + $monologContextData = $this->getArrayFieldFromRecord($record, 'context'); + + if ($monologContextData !== []) { + $scope->setExtra('monolog.context', $monologContextData); + } + + $monologExtraData = $this->getArrayFieldFromRecord($record, 'extra'); + + if ($monologExtraData !== []) { + $scope->setExtra('monolog.extra', $monologExtraData); + } + } + + $this->hub->captureEvent($event, $hint); + }); + } + + /** + * @param array|LogRecord $record + */ + private function hasThrowable($record): bool + { + $exception = $this->getArrayFieldFromRecord($record, 'context')[self::CONTEXT_EXCEPTION_KEY] ?? null; + + return $exception instanceof \Throwable; + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getArrayFieldFromRecord($record, string $field): array + { + if (isset($record[$field]) && \is_array($record[$field])) { + return $record[$field]; + } + + return []; + } +} diff --git a/src/Monolog/LogsHandler.php b/src/Monolog/LogsHandler.php index 17c67d88d0..94aa16ab28 100644 --- a/src/Monolog/LogsHandler.php +++ b/src/Monolog/LogsHandler.php @@ -63,7 +63,7 @@ public function handle($record): bool if (!$this->isHandling($record)) { return false; } - // Do not collect logs for exceptions, they should be handled seperately by the `Handler` or `captureException` + // Do not collect logs for exceptions, they should be handled separately by `ExceptionToSentryIssueHandler` or `captureException` if (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) { return false; } diff --git a/tests/Monolog/LogToSentryIssueHandlerTest.php b/tests/Monolog/LogToSentryIssueHandlerTest.php new file mode 100644 index 0000000000..7bbff023b4 --- /dev/null +++ b/tests/Monolog/LogToSentryIssueHandlerTest.php @@ -0,0 +1,295 @@ + $record + * @param array $expectedExtra + */ + public function testHandleCapturesLogMessageAsIssue(bool $fillExtraContext, $record, Severity $expectedSeverity, array $expectedExtra): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with( + $this->callback(function (Event $event) use ($expectedSeverity): bool { + $this->assertEquals($expectedSeverity, $event->getLevel()); + $this->assertSame('foo bar', $event->getMessage()); + $this->assertSame('monolog.channel.foo', $event->getLogger()); + + return true; + }), + $this->callback(function (EventHint $hint): bool { + $this->assertNull($hint->exception); + $this->assertNull($hint->mechanism); + $this->assertNull($hint->stacktrace); + $this->assertSame([], $hint->extra); + + return true; + }), + $this->callback(function (Scope $scopeArg) use ($expectedExtra): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedExtra, $event->getExtra()); + + return true; + }) + ); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, true, $fillExtraContext); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleReturnsTrueWhenBubblingDisabled(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->isInstanceOf(Event::class), $this->isInstanceOf(EventHint::class), $this->isInstanceOf(Scope::class)); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING, false); + $record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + public function testHandleIgnoresRecordsWithThrowableExceptionContext(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureEvent'); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => new \RuntimeException('boom'), + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleCapturesRecordsWithNonThrowableExceptionContext(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with( + $this->isInstanceOf(Event::class), + $this->isInstanceOf(EventHint::class), + $this->callback(function (Scope $scopeArg): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'exception' => 'not an exception', + ], + ], $event->getExtra()); + + return true; + }) + ); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false, true); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => 'not an exception', + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + public function testHandleIgnoresRecordsBelowThreshold(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureEvent'); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::ERROR, false); + $record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []); + + $this->assertFalse($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testLegacyIsHandlingUsesMinimalLevelRecord(): void + { + if (Logger::API >= 3) { + $this->markTestSkipped('Test only works for Monolog < 3'); + } + + $handler = new LogToSentryIssueHandler(new Hub($this->createMock(ClientInterface::class), new Scope()), Logger::WARNING); + + $this->assertTrue($handler->isHandling(['level' => Logger::WARNING])); + $this->assertFalse($handler->isHandling(['level' => Logger::INFO])); + } + + public function testLogAndExceptionIssueHandlersReplaceLegacyHandlerUseCases(): void + { + $client = ClientBuilder::create() + ->setTransport(StubTransport::getInstance()) + ->getClient(); + $hub = new Hub($client, new Scope()); + + $logger = new Logger('channel.foo', [ + new LogToSentryIssueHandler($hub, Logger::WARNING, true, true), + new ExceptionToSentryIssueHandler($hub, Logger::WARNING), + ]); + + $logger->warning('plain warning', [ + 'foo' => 'bar', + ]); + + $exception = new \RuntimeException('boom'); + $logger->error('exception error', [ + 'exception' => $exception, + 'foo' => 'bar', + ]); + + $this->assertCount(2, StubTransport::$events); + + $logEvent = StubTransport::$events[0]; + $this->assertSame('plain warning', $logEvent->getMessage()); + $this->assertEquals(Severity::warning(), $logEvent->getLevel()); + $this->assertSame('monolog.channel.foo', $logEvent->getLogger()); + $this->assertSame([], $logEvent->getExceptions()); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'foo' => 'bar', + ], + ], $logEvent->getExtra()); + + $exceptionEvent = StubTransport::$events[1]; + $this->assertNull($exceptionEvent->getMessage()); + $this->assertCount(1, $exceptionEvent->getExceptions()); + $this->assertSame(\RuntimeException::class, $exceptionEvent->getExceptions()[0]->getType()); + $this->assertSame('boom', $exceptionEvent->getExceptions()[0]->getValue()); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::ERROR), + 'monolog.message' => 'exception error', + 'monolog.context' => [ + 'foo' => 'bar', + ], + ], $exceptionEvent->getExtra()); + } + + /** + * @return iterable, Severity, array}> + */ + public static function capturedRecordsDataProvider(): iterable + { + foreach ([ + Logger::DEBUG => Severity::debug(), + Logger::INFO => Severity::info(), + Logger::NOTICE => Severity::info(), + Logger::WARNING => Severity::warning(), + Logger::ERROR => Severity::error(), + Logger::CRITICAL => Severity::fatal(), + Logger::ALERT => Severity::fatal(), + Logger::EMERGENCY => Severity::fatal(), + ] as $level => $severity) { + yield Logger::getLevelName($level) => [ + false, + RecordFactory::create('foo bar', $level, 'channel.foo', [], []), + $severity, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName($level), + ], + ]; + } + + yield 'with context and extra' => [ + true, + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + Severity::warning(), + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'foo' => 'bar', + ], + 'monolog.extra' => [ + 'bar' => 'baz', + ], + ], + ]; + + yield 'without context and extra by default' => [ + false, + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + Severity::warning(), + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + ], + ]; + } +} From c9dd4ad27d173626e91d431f439f0f36249e56c0 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 29 Apr 2026 14:28:41 +0200 Subject: [PATCH 64/89] fix: invalid sample_rand is ignored (#2077) --- src/Tracing/Traits/TraceHeaderParserTrait.php | 35 ++++++++-- tests/State/HubTest.php | 11 ++++ tests/Tracing/PropagationContextTest.php | 35 ++++++++++ tests/Tracing/TransactionContextTest.php | 66 +++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/Tracing/Traits/TraceHeaderParserTrait.php b/src/Tracing/Traits/TraceHeaderParserTrait.php index 1a5c0f7c08..1b1c228e48 100644 --- a/src/Tracing/Traits/TraceHeaderParserTrait.php +++ b/src/Tracing/Traits/TraceHeaderParserTrait.php @@ -92,10 +92,11 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin } // Store the propagated trace sample rand or generate a new one - if ($samplingContext->has('sample_rand')) { - $result['sampleRand'] = (float) $samplingContext->get('sample_rand'); - } else { - if ($samplingContext->has('sample_rate') && $result['parentSampled'] !== null) { + if ($hasSentryTrace) { + $incomingSampleRand = self::parseSampleRand($samplingContext); + if ($incomingSampleRand !== null) { + $result['sampleRand'] = $incomingSampleRand; + } elseif ($samplingContext->has('sample_rate') && $result['parentSampled'] !== null) { if ($result['parentSampled'] === true) { // [0, rate) $result['sampleRand'] = round(mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax() * (float) $samplingContext->get('sample_rate'), 6); @@ -112,6 +113,32 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin return $result; } + private static function parseSampleRand(DynamicSamplingContext $samplingContext): ?float + { + $sampleRand = $samplingContext->get('sample_rand'); + if ($sampleRand === null) { + return null; + } + + if (is_numeric($sampleRand)) { + $sampleRandAsFloat = (float) $sampleRand; + if ($sampleRandAsFloat >= 0.0 && $sampleRandAsFloat < 1.0) { + return $sampleRandAsFloat; + } + } + + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + if ($client !== null) { + $client->getOptions()->getLoggerOrNullLogger()->debug( + 'Ignoring invalid sentry-sample_rand baggage value because it must be a numeric value in the range [0, 1).', + ['sample_rand' => $sampleRand] + ); + } + + return null; + } + private static function shouldContinueTrace(DynamicSamplingContext $samplingContext): bool { $hub = SentrySdk::getCurrentHub(); diff --git a/tests/State/HubTest.php b/tests/State/HubTest.php index 19ec34c48e..edd8a5987a 100644 --- a/tests/State/HubTest.php +++ b/tests/State/HubTest.php @@ -760,6 +760,17 @@ public static function startTransactionDataProvider(): iterable false, ]; + yield 'Invalid incoming sample_rand is ignored' => [ + new Options([ + 'traces_sample_rate' => 1.0, + ]), + TransactionContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', + 'sentry-sample_rand=2.0' + ), + true, + ]; + yield 'Out of range sample rate returned from traces_sampler (lower than minimum)' => [ new Options([ 'traces_sampler' => static function (): float { diff --git a/tests/Tracing/PropagationContextTest.php b/tests/Tracing/PropagationContextTest.php index a63db80aea..beefa0cb1d 100644 --- a/tests/Tracing/PropagationContextTest.php +++ b/tests/Tracing/PropagationContextTest.php @@ -136,6 +136,41 @@ public function testGetTraceContext(): void ], $propagationContext->getTraceContext()); } + /** + * @dataProvider invalidSampleRandDataProvider + */ + public function testInvalidSampleRandIsIgnored(string $sampleRand): void + { + $propagationContext = PropagationContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-sample_rate=0.4,sentry-sample_rand=' . rawurlencode($sampleRand) + ); + + $generatedSampleRand = $propagationContext->getSampleRand(); + + $this->assertNotNull($generatedSampleRand); + $this->assertGreaterThanOrEqual(0.0, $generatedSampleRand); + $this->assertLessThan(0.4, $generatedSampleRand); + } + + public function testSampleRandIsIgnoredWithoutSentryTraceHeader(): void + { + $propagationContext = PropagationContext::fromHeaders('', 'sentry-sample_rand=-1.0'); + $sampleRand = $propagationContext->getSampleRand(); + + $this->assertNotNull($sampleRand); + $this->assertGreaterThanOrEqual(0.0, $sampleRand); + $this->assertLessThanOrEqual(1.0, $sampleRand); + } + + public static function invalidSampleRandDataProvider(): iterable + { + yield ['-1.0']; + yield ['1']; + yield ['2.0']; + yield ['foo']; + } + public function testSampleRandRangeWhenParentNotSampledAndSampleRateProvided(): void { $propagationContext = PropagationContext::fromHeaders( diff --git a/tests/Tracing/TransactionContextTest.php b/tests/Tracing/TransactionContextTest.php index 709bc60abb..542dae623c 100644 --- a/tests/Tracing/TransactionContextTest.php +++ b/tests/Tracing/TransactionContextTest.php @@ -5,6 +5,11 @@ namespace Sentry\Tests\Tracing; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Sentry\ClientInterface; +use Sentry\Options; +use Sentry\SentrySdk; +use Sentry\State\Hub; use Sentry\Tracing\DynamicSamplingContext; use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; @@ -135,6 +140,67 @@ public static function tracingDataProvider(): iterable ]; } + /** + * @dataProvider invalidSampleRandDataProvider + */ + public function testInvalidSampleRandIsIgnored(string $sampleRand): void + { + $context = TransactionContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-sample_rate=0.4,sentry-sample_rand=' . rawurlencode($sampleRand) + ); + + $generatedSampleRand = $context->getMetadata()->getSampleRand(); + + $this->assertNotNull($generatedSampleRand); + $this->assertGreaterThanOrEqual(0.0, $generatedSampleRand); + $this->assertLessThan(0.4, $generatedSampleRand); + } + + public function testSampleRandIsIgnoredWithoutSentryTraceHeader(): void + { + $context = TransactionContext::fromHeaders('', 'sentry-sample_rand=-1.0'); + $sampleRand = $context->getMetadata()->getSampleRand(); + + $this->assertNotNull($sampleRand); + $this->assertGreaterThanOrEqual(0.0, $sampleRand); + $this->assertLessThanOrEqual(1.0, $sampleRand); + } + + public function testInvalidSampleRandIsLogged(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('debug') + ->with( + $this->stringContains('Ignoring invalid sentry-sample_rand baggage value'), + ['sample_rand' => '-1.0'] + ); + + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions') + ->willReturn(new Options(['logger' => $logger])); + + SentrySdk::setCurrentHub(new Hub($client)); + + try { + TransactionContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', + 'sentry-sample_rand=-1.0' + ); + } finally { + SentrySdk::setCurrentHub(new Hub()); + } + } + + public static function invalidSampleRandDataProvider(): iterable + { + yield ['-1.0']; + yield ['1']; + yield ['2.0']; + yield ['foo']; + } + public function testSampleRandRangeWhenParentNotSampledAndSampleRateProvided(): void { $context = TransactionContext::fromHeaders( From f98800e816266494f64ed0412454a90444d4687e Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 30 Apr 2026 13:49:50 +0200 Subject: [PATCH 65/89] docs: remove excimer hint in composer suggestions (#2078) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9b3c853956..121fa5817e 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "spiral/roadrunner-worker": "^3.6" }, "suggest": { - "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension. (Please use 1.2.3 for FrankenPHP or PHP ZTS)", + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." }, "conflict": { From 2e1b5c556a1bdab16307542c9dbf6f600a55a8d8 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 30 Apr 2026 14:01:10 +0200 Subject: [PATCH 66/89] Prepare 4.26.0 (#2079) --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a721d08333..e442f81d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # CHANGELOG +## 4.26.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.26.0. + +### Features + +- Add `AgentClient` and `AgentClientBuilder` to hand off envelopes to a local Sentry agent. [(#2062)](https://github.com/getsentry/sentry-php/pull/2062) +- Add fallback HTTP delivery for `AgentClient` when the local Sentry agent is unavailable. [(#2072)](https://github.com/getsentry/sentry-php/pull/2072) +- Add `LogToSentryIssueHandler` Monolog handler to capture log messages as Sentry issues. [(#2075)](https://github.com/getsentry/sentry-php/pull/2075) + +### Bug Fixes + +- Respect `send_default_pii` before attaching user attributes to Sentry logs. [(#2076)](https://github.com/getsentry/sentry-php/pull/2076) +- Ignore invalid propagated `sentry-sample_rand` baggage values and generate a valid sample random value instead. [(#2077)](https://github.com/getsentry/sentry-php/pull/2077) + ## 4.25.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.25.0. From 7597fd10c443929c62489d7cf38d1cb8341d6608 Mon Sep 17 00:00:00 2001 From: stayallive <1090754+stayallive@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:50:22 +0000 Subject: [PATCH 67/89] release: 4.26.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 2793c4830e..ae67f67e65 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.25.0'; + public const SDK_VERSION = '4.26.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 81b8f963fbee3c31304999a985de22cec83e9e73 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 5 May 2026 14:54:48 +0200 Subject: [PATCH 68/89] feat(profiling): add `profiles_sampler` option (#2082) --- src/Options.php | 32 +++++++++++ src/State/Hub.php | 15 ++++- src/functions.php | 1 + tests/OptionsTest.php | 7 +++ tests/State/HubTest.php | 121 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 2 deletions(-) diff --git a/src/Options.php b/src/Options.php index c6398433d1..7e8afbfc6d 100644 --- a/src/Options.php +++ b/src/Options.php @@ -293,6 +293,36 @@ public function setProfilesSampleRate(?float $sampleRate): self return $this; } + /** + * Gets a callback that will be invoked when we sample a profile. + * + * @phpstan-return null|callable(Tracing\SamplingContext): float + */ + public function getProfilesSampler(): ?callable + { + /** @var callable(Tracing\SamplingContext): float|null $value */ + $value = $this->options['profiles_sampler']; + + return $value; + } + + /** + * Sets a callback that will be invoked when we take the profiling sampling decision. + * Return a number between 0 and 1 to define the sample rate for the provided SamplingContext. + * + * @param ?callable $sampler The sampler + * + * @phpstan-param null|callable(Tracing\SamplingContext): float $sampler + */ + public function setProfilesSampler(?callable $sampler): self + { + $options = array_merge($this->options, ['profiles_sampler' => $sampler]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Gets whether tracing is enabled or not. The feature is enabled when at * least one of the `traces_sample_rate` and `traces_sampler` options is @@ -1395,6 +1425,7 @@ private function configureOptions(OptionsResolver $resolver): void 'traces_sample_rate' => null, 'traces_sampler' => null, 'profiles_sample_rate' => null, + 'profiles_sampler' => null, 'attach_stacktrace' => false, /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. @@ -1474,6 +1505,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); + $resolver->setAllowedTypes('profiles_sampler', ['null', 'callable']); $resolver->setAllowedTypes('attach_stacktrace', 'bool'); $resolver->setAllowedTypes('attach_metric_code_locations', 'bool'); $resolver->setAllowedTypes('context_lines', ['null', 'int']); diff --git a/src/State/Hub.php b/src/State/Hub.php index eeaf7bb282..a2ed08e1d8 100644 --- a/src/State/Hub.php +++ b/src/State/Hub.php @@ -328,9 +328,20 @@ public function startTransaction(TransactionContext $context, array $customSampl $transaction->initSpanRecorder(); - $profilesSampleRate = $options->getProfilesSampleRate(); + $profilesSampleSource = 'config:profiles_sample_rate'; + $profilesSampler = $options->getProfilesSampler(); + + if ($profilesSampler !== null) { + $profilesSampleRate = $profilesSampler($samplingContext); + $profilesSampleSource = 'config:profiles_sampler'; + } else { + $profilesSampleRate = $options->getProfilesSampleRate(); + } + if ($profilesSampleRate === null) { - $logger->info(\sprintf('Transaction [%s] is not profiling because `profiles_sample_rate` option is not set.', (string) $transaction->getTraceId())); + $logger->info(\sprintf('Transaction [%s] is not profiling because neither `profiles_sample_rate` nor `profiles_sampler` option is set.', (string) $transaction->getTraceId())); + } elseif (!$this->isValidSampleRate($profilesSampleRate)) { + $logger->warning(\sprintf('Transaction [%s] is not profiling because profile sample rate (decided by %s) is invalid.', (string) $transaction->getTraceId(), $profilesSampleSource)); } elseif ($this->sample($profilesSampleRate)) { $logger->info(\sprintf('Transaction [%s] started profiling because it was sampled.', (string) $transaction->getTraceId())); diff --git a/src/functions.php b/src/functions.php index 44f5b2d0ba..f04024c308 100644 --- a/src/functions.php +++ b/src/functions.php @@ -58,6 +58,7 @@ * org_id?: int|null, * prefixes?: array, * profiles_sample_rate?: int|float|null, + * profiles_sampler?: callable|null, * release?: string|null, * sample_rate?: float|int, * send_attempts?: int, diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index f788f7391f..9b48eb8bd5 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -163,6 +163,13 @@ static function (): void {}, 'setProfilesSampleRate', ]; + yield [ + 'profiles_sampler', + static function (): void {}, + 'getProfilesSampler', + 'setProfilesSampler', + ]; + yield [ 'attach_stacktrace', false, diff --git a/tests/State/HubTest.php b/tests/State/HubTest.php index edd8a5987a..25b812480d 100644 --- a/tests/State/HubTest.php +++ b/tests/State/HubTest.php @@ -834,6 +834,127 @@ public function testStartTransactionWithCustomSamplingContext(): void $hub->startTransaction(new TransactionContext(), $customSamplingContext); } + public function testStartTransactionStartsProfilerWithProfilesSampler(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->exactly(2)) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 1.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNotNull($transaction->getProfiler()); + } + + public function testStartTransactionDoesNotStartProfilerWhenProfilesSamplerReturnsZero(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 0.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testStartTransactionPrefersProfilesSamplerOverProfilesSampleRate(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 0.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testStartTransactionWithProfilesSamplerReceivesCustomSamplingContext(): void + { + $customSamplingContext = ['a' => 'b']; + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => function (SamplingContext $samplingContext) use ($customSamplingContext): float { + $this->assertSame($samplingContext->getAdditionalContext(), $customSamplingContext); + + return 0.0; + }, + ])); + + $hub = new Hub($client); + $hub->startTransaction(new TransactionContext(), $customSamplingContext); + } + + public function testStartTransactionDoesNotStartProfilerWhenProfilesSamplerReturnsInvalidValue(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): string { + return 'foo'; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testStartTransactionDoesNotCallProfilesSamplerWhenTransactionIsNotSampled(): void + { + $profilesSamplerInvoked = false; + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 0.0, + 'profiles_sampler' => static function () use (&$profilesSamplerInvoked): float { + $profilesSamplerInvoked = true; + + return 1.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertFalse($transaction->getSampled()); + $this->assertFalse($profilesSamplerInvoked); + $this->assertNull($transaction->getProfiler()); + } + public function testStartTransactionUpdatesTheDscSampleRate(): void { $client = $this->createMock(ClientInterface::class); From bce92aeed860f52b5afee576d7e69193ac4f1739 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 19:21:32 +0200 Subject: [PATCH 69/89] chore(deps): bump getsentry/github-workflows from 71588ddf95134f804e82c5970a8098588e2eaecd to 43bf14b190c12080cfbedf2d2c82337bc559a0e1 (#2081) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 10fe894067..a995f6cfad 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@71588ddf95134f804e82c5970a8098588e2eaecd + - uses: getsentry/github-workflows/validate-pr@43bf14b190c12080cfbedf2d2c82337bc559a0e1 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 2d9a79949148c375d8369227a60da98f0e0f9f3a Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 5 May 2026 20:48:59 +0200 Subject: [PATCH 70/89] feat(ci): add mago to CI pipeline (#2020) --- .github/workflows/ci.yml | 3 +- .github/workflows/static-analysis.yaml | 17 + analysis-baseline.toml | 1807 ++++++++++++++++++++++++ composer.json | 5 +- mago.toml | 11 + src/Util/PHPVersion.php | 1 + src/functions.php | 7 +- 7 files changed, 1845 insertions(+), 6 deletions(-) create mode 100644 analysis-baseline.toml create mode 100644 mago.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3996341f7..63d455d871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: # These dependencies are not used running the tests but can cause deprecation warnings so we remove them before running the tests - name: Remove unused dependencies - run: composer remove phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update + run: composer remove carthage-software/mago phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update - name: Remove RoadRunner dependencies on unsupported PHP versions if: ${{ matrix.os == 'windows-latest' || matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} @@ -242,4 +242,3 @@ jobs: - name: Run PHPUnit tests (excluding PHPT) run: vendor/bin/phpunit tests --test-suffix Test.php --verbose - diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index ce91800b62..4d3e901111 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -47,3 +47,20 @@ jobs: - name: Run script run: vendor/bin/phpstan analyse + mago: + name: Mago + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + with: + php-version: '8.4' + + - name: Install dependencies + run: composer update --no-progress --no-interaction --prefer-dist + + - name: Run script + run: composer mago diff --git a/analysis-baseline.toml b/analysis-baseline.toml new file mode 100644 index 0000000000..6259f5930b --- /dev/null +++ b/analysis-baseline.toml @@ -0,0 +1,1807 @@ +variant = "loose" + +[[issues]] +file = "src/Agent/Transport/AgentClient.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Agent/Transport/AgentClient.php" +code = "redundant-cast" +message = "Redundant cast to `(string)`: the expression already has this type." +count = 1 + +[[issues]] +file = "src/Client.php" +code = "implicit-to-string-cast" +message = 'Implicit conversion to `string` for left operand via `Sentry\Severity::__toString()`.' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 2 + +[[issues]] +file = "src/Client.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Sentry\StacktraceBuilder::buildFromBacktrace`: expected `list, 'class'?: class-string, 'file'?: string, 'function'?: string, 'line'?: int, 'type'?: string}>`, but possibly received `array`.''' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "possibly-null-argument" +message = 'Argument #1 of method `Sentry\ExceptionDataBag::__construct` is possibly `null`, but parameter type `Throwable` does not accept it.' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "possibly-null-argument" +message = 'Argument #1 of method `Sentry\StacktraceBuilder::buildFromException` is possibly `null`, but parameter type `Throwable` does not accept it.' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "possibly-null-operand" +message = 'Possibly null left operand used in string concatenation (type `Sentry\Severity|null`).' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "redundant-docblock-type" +message = "Redundant docblock type for variable `$result`." +count = 1 + +[[issues]] +file = "src/Client.php" +code = "unused-template-parameter" +message = 'Template parameter `T` is never used in method `Sentry\Client::getIntegration`.' +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #1 of `Sentry\ClientReport\DiscardedEvent::__construct`: expected `string`, but provided type `array-key` is less specific.' +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "redundant-comparison" +message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-false-operand" +message = "Left operand in `==` comparison might be `false` (type `false|int`)." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #1 of function `strrpos` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #1 of function `substr` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #2 of function `explode` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #2 of function `preg_match` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Dsn::__construct` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = 'Argument #5 of method `Sentry\Dsn::__construct` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = 'Argument #6 of method `Sentry\Dsn::__construct` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'host' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 2 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'path' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 4 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'scheme' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'user' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 2 + +[[issues]] +file = "src/Dsn.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 2 + +[[issues]] +file = "src/Dsn.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "deprecated-method" +message = "Call to deprecated method: `ReflectionProperty::setAccessible`." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "less-specific-nested-argument-type" +message = '''Argument type mismatch for argument #1 of `Closure::fromCallable`: expected `(callable(...mixed=): mixed)`, but provided type `list{Sentry\ErrorHandler&static|sentry\errorhandler, string('handleError')}` is less specific.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "less-specific-nested-argument-type" +message = '''Argument type mismatch for argument #1 of `Closure::fromCallable`: expected `(callable(...mixed=): mixed)`, but provided type `list{Sentry\ErrorHandler&static|sentry\errorhandler, string('handleException')}` is less specific.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "less-specific-nested-argument-type" +message = '''Argument type mismatch for argument #1 of `Closure::fromCallable`: expected `(callable(...mixed=): mixed)`, but provided type `list{Sentry\ErrorHandler&static|sentry\errorhandler, string('handleFatalError')}` is less specific.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Sentry\ErrorHandler::cleanBacktraceFromErrorHandlerFrames`: expected `list, 'class'?: class-string, 'file'?: string, 'function'?: string, 'line'?: int, 'type'?: string}>`, but possibly received `array`.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "redundant-comparison" +message = "Redundant `>=` comparison: left-hand side is always greater than or equal to right-hand side." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "write-only-property" +message = "Property `$reservedMemory` is written to but never read." +count = 1 + +[[issues]] +file = "src/Event.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Event.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Event.php" +code = "no-value" +message = "Argument #1 passed to function `get_debug_type` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/EventHint.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 4 + +[[issues]] +file = "src/EventHint.php" +code = "no-value" +message = "Argument #1 passed to function `get_debug_type` has type `never`, meaning it cannot produce a value." +count = 4 + +[[issues]] +file = "src/EventHint.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 3 + +[[issues]] +file = "src/EventHint.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$extra` is already `array`." +count = 1 + +[[issues]] +file = "src/FrameBuilder.php" +code = "invalid-operand" +message = "Right operand in `&&` operation is an `array`." +count = 1 + +[[issues]] +file = "src/FrameBuilder.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/FrameBuilder.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "deprecated-function" +message = "Call to deprecated function: `curl_close`." +count = 2 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 2 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "less-specific-argument" +message = "Argument type mismatch for argument #1 of `curl_share_setopt`: expected `CurlShareHandle`, but provided type `null|object|resource` is less specific." +count = 2 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\HttpClient\Response::__construct`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Util\Http::parseResponseHeaders`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-argument" +message = "Invalid argument type for argument #1 of `version_compare`: expected `string`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-property-type-coercion" +message = "A value with a less specific type `mixed` is being assigned to property `$$shareHandle` (null|object|resource)." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "non-existent-function" +message = 'Could not find definition for function `Sentry\HttpClient\curl_share_init_persistent` (also tried as `curl_share_init_persistent` in a broader scope).' +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "possibly-false-array-access" +message = "Cannot perform array access on possibly `false` value." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "possibly-null-argument" +message = "Argument #1 of function `curl_share_setopt` is possibly `null`, but parameter type `CurlShareHandle` does not accept it." +count = 2 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "redundant-comparison" +message = "Redundant `<` comparison: left-hand side is never less than right-hand side." +count = 2 + +[[issues]] +file = "src/HttpClient/Request.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/HttpClient/Response.php" +code = "less-specific-argument" +message = "Argument type mismatch for argument #1 of `strtolower`: expected `string`, but provided type `array-key` is less specific." +count = 1 + +[[issues]] +file = "src/HttpClient/Response.php" +code = "property-type-coercion" +message = "A value of a less specific type `array{'': array-key, ...}` is being assigned to property `$$headerNames` (array)." +count = 1 + +[[issues]] +file = "src/Integration/FrameContextifierIntegration.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Integration\FrameContextifierIntegration::addContextToStacktraceFrames` is possibly `null`, but parameter type `Sentry\Stacktrace` does not accept it.' +count = 1 + +[[issues]] +file = "src/Integration/FrameContextifierIntegration.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Integration\FrameContextifierIntegration::getSourceCodeExcerpt` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "invalid-property-assignment-value" +message = 'Invalid type for property `$integrations`: expected `array, bool>`, but got `array|string, bool>`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Integration\IntegrationRegistry::getIntegrationsToSetup`: expected `array`, but found `list<(callable(array): Sentry\Integration\IntegrationInterface)|Sentry\Integration\IntegrationInterface>`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Integration\IntegrationRegistry::setupIntegrations`: expected `array, Sentry\Integration\IntegrationInterface>`, but found `array`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `array_map`: expected `(callable((callable(array): Sentry\Integration\IntegrationInterface)|object): string)|(callable((callable(array): Sentry\Integration\IntegrationInterface)|object, ('S.array_map() extends mixed)): string)|null`, but possibly received `string('get_class')`.''' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `get_class`: expected `object`, but possibly received `(callable(array): Sentry\Integration\IntegrationInterface)|Sentry\Integration\IntegrationInterface`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "redundant-type-comparison" +message = 'Redundant type assertion: `$userIntegrations` is already `array): Sentry\Integration\IntegrationInterface)|Sentry\Integration\IntegrationInterface>`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "unreachable-else-clause" +message = "Unreachable else clause" +count = 1 + +[[issues]] +file = "src/Integration/ModulesIntegration.php" +code = "no-value" +message = "Argument #1 passed to function `array_keys` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/Integration/ModulesIntegration.php" +code = "non-existent-class-like" +message = 'Class, interface, enum, or trait `PackageVersions\Versions` not found.' +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "invalid-property-assignment-value" +message = "Invalid type for property `$options`: expected `array{'pii_sanitize_headers': array}`, but got `array`." +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #1 of `Sentry\Integration\RequestIntegration::parseUploadedFiles`: expected `array`, but provided type `array` is less specific.' +count = 2 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "less-specific-return-statement" +message = 'Returned type `array>` is less specific than the declared return type `array>` for function `Sentry\Integration\RequestIntegration::sanitizeHeaders`.' +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::createFromUserIpAddress`: expected `string`, but found `truthy-mixed`.' +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setIpAddress`: expected `null|string`, but found `truthy-mixed`.' +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "no-value" +message = "Argument #2 passed to function `sprintf` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$key` is already `string`." +count = 1 + +[[issues]] +file = "src/Metrics/Metrics.php" +code = "deprecated-class" +message = 'Class `Sentry\Metrics\Metrics` is deprecated and should no longer be used.' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "ambiguous-instantiation-target" +message = "Ambiguous instantiation: the expression used with `new` can resolve to multiple different classes." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Util\TelemetryStorage::push`: expected `Sentry\Metrics\Types\Metric`, but found `nonnull`.' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #5 of `Sentry\Metrics\Types\CounterMetric::__construct`: expected `array`, but possibly received `array`.' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #5 of `Sentry\Metrics\Types\DistributionMetric::__construct`: expected `array`, but possibly received `array`.' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #5 of `Sentry\Metrics\Types\GaugeMetric::__construct`: expected `array`, but possibly received `array`.' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$value` is already `float`." +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "impossible-nonnull-entry-check" +message = "Impossible `isset` check on key `'context'` accessed on `array{'channel': string, 'datetime': DateTimeImmutable, 'extra'?: array, 'level': int, 'message': string}`." +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #5 of `Sentry\Breadcrumb::__construct`: expected `array`, but provided type `array|int` is less specific.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractHandler::__construct`: expected `enum(Monolog\Level)|int(100)|int(200)|int(250)|int(300)|int(400)|int(500)|int(550)|int(600)|string('ALERT')|string('CRITICAL')|string('DEBUG')|string('EMERGENCY')|string('ERROR')|string('INFO')|string('NOTICE')|string('WARNING')|string('alert')|string('critical')|string('debug')|string('emergency')|string('error')|string('info')|string('notice')|string('warning')`, but possibly received `enum(Monolog\Level)|int|string`.''' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Sentry\Monolog\BreadcrumbHandler::getBreadcrumbLevel`: expected `enum(Monolog\Level)|int`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Sentry\Monolog\BreadcrumbHandler::getBreadcrumbType`: expected `int`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #3 of `Sentry\Breadcrumb::__construct`: expected `string`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #4 of `Sentry\Breadcrumb::__construct`: expected `null|string`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-operand" +message = "Possibly invalid type for left operand." +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-operand" +message = "Possibly invalid type for right operand." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityLogLevelTrait.php" +code = "duplicate-definition" +message = 'Trait `Sentry\Monolog\CompatibilityLogLevelTrait` is already defined elsewhere.' +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityLogLevelTrait.php" +code = "redundant-comparison" +message = "Redundant `>=` comparison: left-hand side is always greater than or equal to right-hand side." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityLogLevelTrait.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityProcessingHandlerTrait.php" +code = "duplicate-definition" +message = 'Trait `Sentry\Monolog\CompatibilityProcessingHandlerTrait` is already defined elsewhere.' +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityProcessingHandlerTrait.php" +code = "redundant-comparison" +message = "Redundant `>=` comparison: left-hand side is always greater than or equal to right-hand side." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityProcessingHandlerTrait.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Monolog/ExceptionToSentryIssueHandler.php" +code = "less-specific-return-statement" +message = 'Returned type `array` is less specific than the declared return type `array` for function `Sentry\Monolog\ExceptionToSentryIssueHandler::getArrayFieldFromRecord`.' +count = 1 + +[[issues]] +file = "src/Monolog/ExceptionToSentryIssueHandler.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Monolog/ExceptionToSentryIssueHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractHandler::isHandling`: expected `Monolog\LogRecord`, but possibly received `Monolog\LogRecord|array`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = '''Invalid argument type for argument #1 of `Monolog\Handler\AbstractHandler::__construct`: expected `enum(Monolog\Level)|int(100)|int(200)|int(250)|int(300)|int(400)|int(500)|int(550)|int(600)|string('ALERT')|string('CRITICAL')|string('DEBUG')|string('EMERGENCY')|string('ERROR')|string('INFO')|string('NOTICE')|string('WARNING')|string('alert')|string('critical')|string('debug')|string('emergency')|string('error')|string('info')|string('notice')|string('warning')`, but found `mixed`.''' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Event::setMessage`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\CompatibilityProcessingHandlerTrait::getSeverityFromLevel`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\Handler::getMonologContextData`: expected `array`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\Handler::getMonologExtraData`: expected `array`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "less-specific-return-statement" +message = 'Returned type `array` is less specific than the declared return type `array` for function `Sentry\Monolog\LogToSentryIssueHandler::getArrayFieldFromRecord`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Event::setMessage`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\CompatibilityProcessingHandlerTrait::getSeverityFromLevel`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractHandler::isHandling`: expected `Monolog\LogRecord`, but possibly received `Monolog\LogRecord|array`.' +count = 2 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractProcessingHandler::handle`: expected `Monolog\LogRecord`, but possibly received `Monolog\LogRecord|array`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\CompatibilityLogLevelTrait::getSentryLogLevelFromMonologLevel`: expected `int`, but found `mixed`.' +count = 2 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #1 of `array_merge`: expected `array`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #2 of `Sentry\Logs\LogsAggregator::add`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `array_merge`: expected `array`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 2 + +[[issues]] +file = "src/Options.php" +code = "deprecated-method" +message = 'Call to deprecated method: `Sentry\Options::getEnableTracing`.' +count = 3 + +[[issues]] +file = "src/Options.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-operand" +message = "Right operand in `||` operation has `mixed` type." +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeBreadcrumbCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendCheckInCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendLogCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendMetricsCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendTransactionCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getClassSerializers`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getContextLines`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getDsn`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getEnableLogs`. Saw type `nonnull`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getEnableTracing`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getEnvironment`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getErrorTypes`. Saw type `nonnull`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpClient`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpConnectTimeout`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpProxyAuthentication`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpProxy`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpSslNativeCa`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpSslVerifyPeer`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpTimeout`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getIgnoreExceptions`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getIgnoreTransactions`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getInAppExcludedPaths`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getInAppIncludedPaths`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getIntegrations`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getLogger`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getMaxBreadcrumbs`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getMaxRequestBodySize`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getMaxValueLength`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getOrgId`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getPrefixes`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getRelease`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getSampleRate`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getServerName`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getSpotlightUrl`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTags`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTracePropagationTargets`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTracesSampleRate`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTracesSampler`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTransport`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::hasDefaultIntegrations`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::isHttpCompressionEnabled`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::shouldAttachMetricCodeLocations`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::shouldAttachStacktrace`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::shouldCaptureSilencedErrors`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::shouldSendDefaultPii`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "property-type-coercion" +message = "A value of a less specific type `array` is being assigned to property `$$options` (array)." +count = 55 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "invalid-method-access" +message = "Attempting to access a method on a non-object type (`unknown-ref(ExcimerLog)`)." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Profiling\Profile::getFormattedData`: expected `array{'device': array{'architecture': string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': string}, 'platform': string, 'profile': array{'frames': array, 'samples': array, 'stacks': array>}, 'release': string, 'runtime': array{'name': string, 'version': string}, 'timestamp': string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': string, 'trace_id': string}, 'version': string}|null`, but found `array{'device': array{'architecture': null|string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': null|string}, 'platform': string('php'), 'profile': array{'frames': array{}|list, 'samples': array{}|non-empty-list, 'stacks': list>}, 'release': string, 'runtime': array{'name': string, 'sapi': null|string, 'version': null|string}, 'timestamp': string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': null|string, 'trace_id': null|string}, 'version': string}`.''' +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "less-specific-nested-return-statement" +message = '''Returned type `array{}|non-empty-list` is less specific than the declared return type `array}>` for function `Sentry\Profiling\Profile::prepareStacks` due to nested 'mixed'.''' +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-class-like" +message = "Cannot find class, interface, enum, or type alias `ExcimerLog`." +count = 2 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-class-like" +message = "Class, Interface, or Trait `ExcimerLogEntry` does not exist." +count = 5 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-method" +message = "Method `gettimestamp` does not exist on type `ExcimerLogEntry`." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-method" +message = "Method `gettrace` does not exist on type `ExcimerLogEntry`." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "possibly-invalid-iterator" +message = "The expression provided to `foreach` (type `array}>|unknown-ref(ExcimerLog)`) might not be iterable at runtime." +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "invalid-method-access" +message = "Attempting to access a method on a non-object type (`unknown-ref(ExcimerProfiler)`)." +count = 3 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "mixed-argument" +message = '''Invalid argument type for argument #1 of `Sentry\Profiling\Profile::setExcimerLog`: expected `array}>|unknown-ref(ExcimerLog)`, but found `mixed`.''' +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "non-existent-class" +message = "Class `ExcimerProfiler` not found." +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "non-existent-class-like" +message = "Cannot find class, interface, enum, or type alias `ExcimerProfiler`." +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "non-existent-constant" +message = "Undefined constant: `EXCIMER_REAL`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "generic-object-iteration" +message = "Iterating over a generic `object`. This will iterate its public properties." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "impossible-type-comparison" +message = "Impossible type assertion: `$callable` of type `(callable(...mixed): mixed)` can never be `object`." +count = 2 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "invalid-operand" +message = "Invalid type `scalar` for middle operand in string concatenation." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "invalid-operand" +message = "Left operand in `&&` operation is an `object`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Serializer\AbstractSerializer::serializeValue`: expected `bool|float|int|null|string`, but found `bool|null|numeric`.' +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "mixed-argument" +message = "Invalid argument type for argument #1 of `method_exists`: expected `class-string|object`, but found `nonnull`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 5 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "no-value" +message = "Argument #1 passed to function `method_exists` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "no-value" +message = "Argument #1 passed to method `ReflectionMethod::__construct` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "null-operand" +message = "Right operand in `!=` comparison is `null`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "possibly-invalid-argument" +message = "Possible argument type mismatch for argument #1 of `ReflectionFunction::__construct`: expected `(closure(...mixed=): mixed)|string`, but possibly received `Closure`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "possibly-null-operand" +message = "Left operand in `!=` comparison might be `null` (type `null|string`)." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "redundant-cast" +message = "Redundant cast to `(string)`: the expression already has this type." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is always falsy and right operand is not evaluated." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is always truthy and right operand is evaluated." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/CheckInItem.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/EventItem.php" +code = "deprecated-method" +message = 'Call to deprecated method: `Sentry\UserDataBag::getSegment`.' +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/EventItem.php" +code = "mixed-array-assignment" +message = "Unsafe array assignment on type `mixed`." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/EventItem.php" +code = "possibly-null-argument" +message = 'Argument #1 of method `Sentry\Util\Str::vsprintfOrNull` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/ProfileItem.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/TransactionItem.php" +code = "deprecated-method" +message = 'Call to deprecated method: `Sentry\UserDataBag::getSegment`.' +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/TransactionItem.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Serializer\EnvelopItems\TransactionItem::serializeSpan`: expected `array{'data'?: array, 'description'?: string, 'op'?: string, 'origin': string, 'parent_span_id'?: string, 'span_id': string, 'start_timestamp': float, 'status'?: string, 'tags'?: array, 'timestamp'?: float, 'trace_id': string}`, but found `array{'data'?: array, 'description'?: null|string, 'op'?: null|string, 'origin': string, 'parent_span_id'?: string, 'span_id': string, 'start_timestamp': float, 'status'?: string, 'tags'?: array, 'timestamp'?: float|null, 'trace_id': string}`.''' +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/TransactionItem.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Serializer/PayloadSerializer.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Serializer/RepresentationSerializer.php" +code = "invalid-operand" +message = "Invalid type `never` for left operand in string concatenation." +count = 1 + +[[issues]] +file = "src/Serializer/Traits/BreadcrumbSeralizerTrait.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Serializer\Traits\BreadcrumbSeralizerTrait::serializeBreadcrumb`: expected `array{'category': string, 'data'?: object, 'level': string, 'message'?: string, 'timestamp': float, 'type': string}`, but found `array{'category': string, 'data'?: stdClass, 'level': string, 'message'?: null|string, 'timestamp': float, 'type': string}`.''' +count = 1 + +[[issues]] +file = "src/Serializer/Traits/StacktraceFrameSeralizerTrait.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Serializer\Traits\StacktraceFrameSeralizerTrait::serializeStacktraceFrame`: expected `array{'abs_path'?: string, 'context_line'?: string, 'filename': string, 'function'?: string, 'in_app': bool, 'lineno': int, 'post_context'?: array, 'pre_context'?: array, 'raw_function'?: string, 'vars'?: array}`, but found `array{'abs_path'?: null|string, 'context_line'?: null|string, 'filename': string, 'function'?: null|string, 'in_app': bool, 'lineno': int, 'post_context'?: array, 'pre_context'?: array, 'raw_function'?: null|string, 'vars'?: array}`.''' +count = 1 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "deprecated-function" +message = "Call to deprecated function: `curl_close`." +count = 2 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 2 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\HttpClient\Response::__construct`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "redundant-comparison" +message = "Redundant `<` comparison: left-hand side is never less than right-hand side." +count = 2 + +[[issues]] +file = "src/Stacktrace.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Stacktrace.php" +code = "no-value" +message = "Argument #1 passed to function `get_debug_type` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/StacktraceBuilder.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `Sentry\Stacktrace::__construct`: expected `array`, but provided type `non-empty-list` is less specific.' +count = 1 + +[[issues]] +file = "src/StacktraceBuilder.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Sentry\StacktraceBuilder::buildFromBacktrace`: expected `list, 'class'?: class-string, 'file'?: string, 'function'?: string, 'line'?: int, 'type'?: string}>`, but possibly received `array`.''' +count = 1 + +[[issues]] +file = "src/State/Hub.php" +code = "mixed-operand" +message = "Right operand in `<` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/State/Hub.php" +code = "possibly-null-operand" +message = "Left operand in `<` comparison might be `null` (type `float|null`)." +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 2 + +[[issues]] +file = "src/State/Scope.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 3 + +[[issues]] +file = "src/State/Scope.php" +code = "mixed-property-type-coercion" +message = 'A value with a less specific type `non-empty-array|non-empty-list<(callable(...mixed): mixed)>` is being assigned to property `$$eventProcessors` (array).' +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "no-value" +message = "Argument #1 passed to function `get_debug_type` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/Tracing/DynamicSamplingContext.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #2 of `Sentry\Tracing\DynamicSamplingContext::set`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Tracing/DynamicSamplingContext.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 4 + +[[issues]] +file = "src/Tracing/DynamicSamplingContext.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Tracing\DynamicSamplingContext::set` is possibly `null`, but parameter type `string` does not accept it.' +count = 2 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "implicit-to-string-cast" +message = 'Implicit conversion to `string` for right operand via `Psr\Http\Message\UriInterface::__toString()`.' +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Tracing\SpanStatus::createFromHttpStatusCode`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-method-access" +message = "Attempting to access a method on a non-object type (`mixed`)." +count = 2 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-method-access" +message = "Attempting to access a method on a non-object type (`nonnull`)." +count = 7 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-operand" +message = "Left operand in `<` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 3 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "non-existent-class-like" +message = 'Class, Interface, or Trait `GuzzleHttp\Exception\RequestException` does not exist.' +count = 3 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "non-existent-method" +message = 'Method `getresponse` does not exist on type `GuzzleHttp\Exception\RequestException`.' +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "possibly-null-argument" +message = "Argument #2 of function `in_array` is possibly `null`, but parameter type `array` does not accept it." +count = 1 + +[[issues]] +file = "src/Tracing/PropagationContext.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Tracing/PropagationContext.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Tracing/PropagationContext.php" +code = "write-only-property" +message = "Property `$parentSampled` is written to but never read." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "invalid-type-cast" +message = "Non numeric string of type `string` implicitly cast to `float`." +count = 4 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "mixed-argument" +message = "Invalid argument type for argument #3 of `sprintf`: expected `Stringable|null|scalar`, but found `nonnull`." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/Tracing/Transaction.php" +code = "incompatible-property-type" +message = 'Property `Sentry\Tracing\Transaction::$transaction` has an incompatible type declaration from docblock.' +count = 1 + +[[issues]] +file = "src/Tracing/Transaction.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Tracing\Transaction::getDynamicSamplingContext`: expected `Sentry\Tracing\DynamicSamplingContext`, but found `Sentry\Tracing\DynamicSamplingContext|null`.' +count = 1 + +[[issues]] +file = "src/Tracing/Transaction.php" +code = "nullable-return-statement" +message = 'Function `Sentry\Tracing\Transaction::getDynamicSamplingContext` is declared to return `Sentry\Tracing\DynamicSamplingContext` but possibly returns a nullable value (inferred as `Sentry\Tracing\DynamicSamplingContext|null`).' +count = 1 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "implicit-to-string-cast" +message = 'Implicit conversion to `string` for left operand via `Sentry\Severity::__toString()`.' +count = 2 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "mixed-argument" +message = "Invalid argument type for argument #3 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 2 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "possibly-null-operand" +message = 'Possibly null left operand used in string concatenation (type `Sentry\Severity|null`).' +count = 2 + +[[issues]] +file = "src/UserDataBag.php" +code = "deprecated-method" +message = 'Call to deprecated method: `Sentry\UserDataBag::setSegment`.' +count = 2 + +[[issues]] +file = "src/UserDataBag.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setEmail`: expected `null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setId`: expected `int|null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setIpAddress`: expected `null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setSegment`: expected `null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setUsername`: expected `null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "no-value" +message = "Argument #1 passed to function `get_debug_type` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$id` is already `int`." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "invalid-iterator" +message = "The expression provided to `foreach` is not iterable. It resolved to type `mixed`, which is not iterable." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #1 of `Sentry\Util\Arr::isList`: expected `array`, but provided type `non-empty-array` is less specific.' +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 3 + +[[issues]] +file = "src/Util/Arr.php" +code = "mixed-operand" +message = "Invalid left operand: type `mixed` cannot be reliably used in string concatenation." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "mixed-operand" +message = "Invalid right operand: type `mixed` cannot be reliably used in string concatenation." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Util/JSON.php" +code = "falsable-return-statement" +message = '''Function `Sentry\Util\JSON::encode` is declared to return `string` but possibly returns 'false' (inferred as `false|non-empty-string`).''' +count = 1 + +[[issues]] +file = "src/Util/JSON.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Util\JSON::encode`: expected `string`, but found `false|non-empty-string`.' +count = 1 + +[[issues]] +file = "src/Util/JSON.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "avoid-catching-error" +message = "Avoid catching 'Error' class instances." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "impossible-condition" +message = "Redundant ternary operator: condition is always falsy." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is never identical to (always false for !==) right-hand side." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "redundant-comparison" +message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/functions.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 diff --git a/composer.json b/composer.json index 121fa5817e..cb1a2b3afb 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" }, "require-dev": { + "carthage-software/mago": "^1.13.3", "friendsofphp/php-cs-fixer": "^3.4", "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", @@ -70,12 +71,14 @@ "check": [ "@cs-check", "@phpstan", + "@mago", "@tests" ], "tests": "vendor/bin/phpunit --verbose", "cs-check": "vendor/bin/php-cs-fixer fix --verbose --diff --dry-run", "cs-fix": "vendor/bin/php-cs-fixer fix --verbose --diff", - "phpstan": "vendor/bin/phpstan analyse" + "phpstan": "vendor/bin/phpstan analyse", + "mago": "vendor/bin/mago --config=mago.toml analyze" }, "config": { "sort-packages": true, diff --git a/mago.toml b/mago.toml new file mode 100644 index 0000000000..d1b63f09d7 --- /dev/null +++ b/mago.toml @@ -0,0 +1,11 @@ +[source] +paths = ["src"] +includes = ["vendor"] +excludes = [ + "tests/resources/**", + "tests/Fixtures/**", + "src/Util/ClockMock.php", +] + +[analyzer] +baseline = "analysis-baseline.toml" diff --git a/src/Util/PHPVersion.php b/src/Util/PHPVersion.php index 3f0394b6a4..c388070346 100644 --- a/src/Util/PHPVersion.php +++ b/src/Util/PHPVersion.php @@ -22,6 +22,7 @@ final class PHPVersion */ public static function parseVersion(string $version = \PHP_VERSION): string { + $matches = []; if (!preg_match(self::VERSION_PARSING_REGEX, $version, $matches)) { return $version; } diff --git a/src/functions.php b/src/functions.php index f04024c308..ecbc8a3496 100644 --- a/src/functions.php +++ b/src/functions.php @@ -290,6 +290,7 @@ function trace(callable $trace, SpanContext $context) { return SentrySdk::getCurrentHub()->withScope(static function (Scope $scope) use ($context, $trace) { $parentSpan = $scope->getSpan(); + $span = null; // If there is a span set on the scope and it's sampled there is an active transaction. // If that is the case we create the child span and set it on the scope. @@ -303,7 +304,7 @@ function trace(callable $trace, SpanContext $context) try { return $trace($scope); } finally { - if (isset($span)) { + if ($span !== null) { $span->finish(); $scope->setSpan($parentSpan); @@ -351,7 +352,7 @@ function getTraceparent(): string if ($client !== null) { $options = $client->getOptions(); - if ($options !== null && $options->isTracingEnabled()) { + if ($options->isTracingEnabled()) { $span = SentrySdk::getCurrentHub()->getSpan(); if ($span !== null) { return $span->toTraceparent(); @@ -398,7 +399,7 @@ function getBaggage(): string if ($client !== null) { $options = $client->getOptions(); - if ($options !== null && $options->isTracingEnabled()) { + if ($options->isTracingEnabled()) { $span = SentrySdk::getCurrentHub()->getSpan(); if ($span !== null) { return $span->toBaggage(); From cf75602a0c0728180ed2bd73093caa81fe98286f Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 6 May 2026 15:54:51 +0200 Subject: [PATCH 71/89] fix(pii): remove guards from aggregators (#2083) --- src/Logs/LogsAggregator.php | 28 +++++++++++++--------------- src/Metrics/MetricsAggregator.php | 28 +++++++++++++--------------- tests/Logs/LogsAggregatorTest.php | 8 ++++---- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 29571175f7..bc0b5f302a 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -90,22 +90,20 @@ public function add( $log->setAttribute('sentry.sdk.version', $client->getSdkVersion()); } - if ($options->shouldSendDefaultPii()) { - $hub->configureScope(static function (Scope $scope) use ($log) { - $user = $scope->getUser(); - if ($user !== null) { - if ($user->getId() !== null) { - $log->setAttribute('user.id', $user->getId()); - } - if ($user->getEmail() !== null) { - $log->setAttribute('user.email', $user->getEmail()); - } - if ($user->getUsername() !== null) { - $log->setAttribute('user.name', $user->getUsername()); - } + $hub->configureScope(static function (Scope $scope) use ($log) { + $user = $scope->getUser(); + if ($user !== null) { + if ($user->getId() !== null) { + $log->setAttribute('user.id', $user->getId()); } - }); - } + if ($user->getEmail() !== null) { + $log->setAttribute('user.email', $user->getEmail()); + } + if ($user->getUsername() !== null) { + $log->setAttribute('user.name', $user->getUsername()); + } + } + }); if (\count($values)) { $log->setAttribute('sentry.message.template', $message); diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index 6f763e1ddf..a4a3aef3e4 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -81,22 +81,20 @@ public function add( $defaultAttributes['sentry.sdk.version'] = $client->getSdkVersion(); } - if ($options->shouldSendDefaultPii()) { - $hub->configureScope(static function (Scope $scope) use (&$defaultAttributes) { - $user = $scope->getUser(); - if ($user !== null) { - if ($user->getId() !== null) { - $defaultAttributes['user.id'] = $user->getId(); - } - if ($user->getEmail() !== null) { - $defaultAttributes['user.email'] = $user->getEmail(); - } - if ($user->getUsername() !== null) { - $defaultAttributes['user.name'] = $user->getUsername(); - } + $hub->configureScope(static function (Scope $scope) use (&$defaultAttributes) { + $user = $scope->getUser(); + if ($user !== null) { + if ($user->getId() !== null) { + $defaultAttributes['user.id'] = $user->getId(); } - }); - } + if ($user->getEmail() !== null) { + $defaultAttributes['user.email'] = $user->getEmail(); + } + if ($user->getUsername() !== null) { + $defaultAttributes['user.name'] = $user->getUsername(); + } + } + }); $release = $options->getRelease(); if ($release !== null) { diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index eb701f5be5..90be692444 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -211,7 +211,7 @@ public function testAttributesAreAddedToLogMessage(): void $this->assertSame('my_user', $attributes->get('user.name')->getValue()); } - public function testUserAttributesAreNotAddedToLogMessageWhenSendDefaultPiiIsDisabled(): void + public function testUserAttributesCanBeSetManuallyWithDefaultPiiOff(): void { $client = ClientBuilder::create([ 'enable_logs' => true, @@ -237,9 +237,9 @@ public function testUserAttributesAreNotAddedToLogMessageWhenSendDefaultPiiIsDis $attributes = $logs[0]->attributes(); - $this->assertNull($attributes->get('user.id')); - $this->assertNull($attributes->get('user.email')); - $this->assertNull($attributes->get('user.name')); + $this->assertSame('unique_id', $attributes->get('user.id')->getValue()); + $this->assertSame('foo@example.com', $attributes->get('user.email')->getValue()); + $this->assertSame('my_user', $attributes->get('user.name')->getValue()); } public function testFlushesImmediatelyWhenThresholdIsReached(): void From ce54c6c93f9987247684c2a7f69687dc2bac18d3 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 6 May 2026 16:31:40 +0200 Subject: [PATCH 72/89] Prepare 4.27.0 (#2084) --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e442f81d8b..ef346b3c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # CHANGELOG +## 4.27.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.27.0. + +### Features + +- Add `profiles_sampler` option. [(#2082)](https://github.com/getsentry/sentry-php/pull/2082) + +### Bug Fixes + +- Preserve manually configured user attributes on logs and metrics when `send_default_pii` is disabled. [(#2083)](https://github.com/getsentry/sentry-php/pull/2083) + +### Misc + +- Add Mago static analysis to CI. [(#2020)](https://github.com/getsentry/sentry-php/pull/2020) + ## 4.26.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.26.0. From 1f0544cff8443ac1d25d6521487118e28381a1c2 Mon Sep 17 00:00:00 2001 From: stayallive <1090754+stayallive@users.noreply.github.com> Date: Wed, 6 May 2026 14:32:16 +0000 Subject: [PATCH 73/89] release: 4.27.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index ae67f67e65..2bb80c7307 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.26.0'; + public const SDK_VERSION = '4.27.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 05574796b5e1e92546f2514b48b1cfccf669ddab Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 11 May 2026 15:15:45 +0200 Subject: [PATCH 74/89] ref: add CodeLocationResolver (#2085) --- analysis-baseline.toml | 12 --- mago.toml | 1 + src/Integration/ModulesIntegration.php | 16 ++-- src/Util/CodeLocationResolver.php | 110 +++++++++++++++++++++++++ tests/CodeLocationResolverTest.php | 94 +++++++++++++++++++++ 5 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 src/Util/CodeLocationResolver.php create mode 100644 tests/CodeLocationResolverTest.php diff --git a/analysis-baseline.toml b/analysis-baseline.toml index 6259f5930b..f214ee80ed 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -438,18 +438,6 @@ code = "unreachable-else-clause" message = "Unreachable else clause" count = 1 -[[issues]] -file = "src/Integration/ModulesIntegration.php" -code = "no-value" -message = "Argument #1 passed to function `array_keys` has type `never`, meaning it cannot produce a value." -count = 1 - -[[issues]] -file = "src/Integration/ModulesIntegration.php" -code = "non-existent-class-like" -message = 'Class, interface, enum, or trait `PackageVersions\Versions` not found.' -count = 1 - [[issues]] file = "src/Integration/RequestIntegration.php" code = "invalid-property-assignment-value" diff --git a/mago.toml b/mago.toml index d1b63f09d7..f8b021c004 100644 --- a/mago.toml +++ b/mago.toml @@ -5,6 +5,7 @@ excludes = [ "tests/resources/**", "tests/Fixtures/**", "src/Util/ClockMock.php", + "vendor/open-telemetry/gen-otlp-protobuf/GPBMetadata/**", ] [analyzer] diff --git a/src/Integration/ModulesIntegration.php b/src/Integration/ModulesIntegration.php index 8148b37782..6dd620d24a 100644 --- a/src/Integration/ModulesIntegration.php +++ b/src/Integration/ModulesIntegration.php @@ -6,7 +6,6 @@ use Composer\InstalledVersions; use Jean85\PrettyVersions; -use PackageVersions\Versions; use Sentry\Event; use Sentry\SentrySdk; use Sentry\State\Scope; @@ -67,12 +66,19 @@ private static function getInstalledPackages(): array return InstalledVersions::getInstalledPackages(); } - if (class_exists(Versions::class)) { + $versionsClass = 'PackageVersions\\Versions'; + + if (class_exists($versionsClass)) { // BC layer for Composer 1, using a transient dependency - /** @var string[] $packages */ - $packages = array_keys(Versions::VERSIONS); + /** @var mixed $versions */ + $versions = \constant($versionsClass . '::VERSIONS'); + + if (\is_array($versions)) { + /** @var string[] $packages */ + $packages = array_keys($versions); - return $packages; + return $packages; + } } // this should not happen diff --git a/src/Util/CodeLocationResolver.php b/src/Util/CodeLocationResolver.php new file mode 100644 index 0000000000..cc968e70c5 --- /dev/null +++ b/src/Util/CodeLocationResolver.php @@ -0,0 +1,110 @@ +frameBuilder = new FrameBuilder($options, $representationSerializer); + } + + /** + * Resolves the first in-app frame from the current backtrace into code + * location metadata. + * + * @return array{'code.filepath': string, 'code.function': string|null, 'code.lineno': int}|null + */ + public function resolve(int $limit = 20): ?array + { + /** @var list $backtrace */ + $backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, $limit); + + return $this->resolveFromBacktrace($backtrace); + } + + /** + * Resolves the first in-app frame from a backtrace into code location metadata. + * + * @param array> $backtrace The backtrace + * + * @phpstan-param list $backtrace + * + * @return array{'code.filepath': string, 'code.function': string|null, 'code.lineno': int}|null + */ + public function resolveFromBacktrace(array $backtrace): ?array + { + $frame = $this->findFirstInAppFrameForBacktrace($backtrace); + + if ($frame === null) { + return null; + } + + return $this->getCodeLocationForFrame($frame); + } + + /** + * Find the first in-app frame for a given backtrace. + * + * @param array> $backtrace The backtrace + * + * @phpstan-param list $backtrace + */ + public function findFirstInAppFrameForBacktrace(array $backtrace): ?Frame + { + $file = Frame::INTERNAL_FRAME_FILENAME; + $line = 0; + + foreach ($backtrace as $backtraceFrame) { + $frame = $this->frameBuilder->buildFromBacktraceFrame($file, $line, $backtraceFrame); + + if ($frame->isInApp()) { + return $frame; + } + + $file = $backtraceFrame['file'] ?? Frame::INTERNAL_FRAME_FILENAME; + $line = $backtraceFrame['line'] ?? 0; + } + + return null; + } + + /** + * Converts a frame into code location metadata. + * + * @return array{'code.filepath': string, 'code.function': string|null, 'code.lineno': int} + */ + public function getCodeLocationForFrame(Frame $frame): array + { + return [ + 'code.filepath' => $frame->getFile(), + 'code.function' => $frame->getFunctionName(), + 'code.lineno' => $frame->getLine(), + ]; + } +} diff --git a/tests/CodeLocationResolverTest.php b/tests/CodeLocationResolverTest.php new file mode 100644 index 0000000000..4a45f8a239 --- /dev/null +++ b/tests/CodeLocationResolverTest.php @@ -0,0 +1,94 @@ +createResolver([ + 'prefixes' => [], + ]); + + $frame = $resolver->findFirstInAppFrameForBacktrace($this->createQueryBacktrace($expectedLine)); + + $this->assertNotNull($frame); + $this->assertSame(__FILE__, $frame->getFile()); + $this->assertSame($expectedLine, $frame->getLine()); + $this->assertSame('App\\Repository\\UserRepository::findActiveUsers', $frame->getFunctionName()); + } + + public function testResolveFromBacktraceReturnsCodeLocationMetadata(): void + { + $expectedLine = 321; + $resolver = $this->createResolver([ + 'prefixes' => [\dirname(__DIR__)], + ]); + + $location = $resolver->resolveFromBacktrace($this->createQueryBacktrace($expectedLine)); + + $this->assertNotNull($location); + $this->assertSame(\DIRECTORY_SEPARATOR . 'tests' . \DIRECTORY_SEPARATOR . 'CodeLocationResolverTest.php', $location['code.filepath']); + $this->assertSame('App\\Repository\\UserRepository::findActiveUsers', $location['code.function']); + $this->assertSame($expectedLine, $location['code.lineno']); + } + + public function testResolveFromBacktraceReturnsNullWithoutInAppFrame(): void + { + $resolver = $this->createResolver(); + + $location = $resolver->resolveFromBacktrace([ + [ + 'file' => Frame::INTERNAL_FRAME_FILENAME, + 'line' => 0, + 'function' => 'internal', + ], + [ + 'class' => 'Doctrine\\DBAL\\Connection', + 'function' => 'executeQuery', + ], + ]); + + $this->assertNull($location); + } + + private function createResolver(array $options = []): CodeLocationResolver + { + $options = new Options($options); + + return new CodeLocationResolver($options, new RepresentationSerializer($options)); + } + + /** + * @return array> + */ + private function createQueryBacktrace(int $line): array + { + return [ + [ + 'file' => Frame::INTERNAL_FRAME_FILENAME, + 'line' => 0, + 'function' => 'internal', + ], + [ + 'file' => __FILE__, + 'line' => $line, + 'class' => 'Doctrine\\DBAL\\Connection', + 'function' => 'executeQuery', + ], + [ + 'class' => 'App\\Repository\\UserRepository', + 'function' => 'findActiveUsers', + ], + ]; + } +} From 4ca6ad1927815271b4418af7579dde4623cb0709 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:56:03 +0200 Subject: [PATCH 75/89] chore(deps): bump getsentry/github-workflows from 43bf14b190c12080cfbedf2d2c82337bc559a0e1 to 24be69680c3a46c03edfbeb05268ec312f77e1e7 (#2088) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index a995f6cfad..09779e493e 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@43bf14b190c12080cfbedf2d2c82337bc559a0e1 + - uses: getsentry/github-workflows/validate-pr@24be69680c3a46c03edfbeb05268ec312f77e1e7 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 58213a2d64ac10eb0452ce4ae44a9e6579c7d4f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:56:14 +0200 Subject: [PATCH 76/89] chore(deps): bump getsentry/craft from 2.26.2 to 2.26.3 (#2086) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 1b27a8b836..c53aa1640e 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@3dc647fee3586e57c7c31eb900fdec7cbb44f23f + uses: getsentry/craft@bae212ca7aec50bb716eafd387c80bcfb28da937 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From c02b3de5b1c4d77dc373ece53c71870017e4d3a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:56:25 +0200 Subject: [PATCH 77/89] chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.26.2 to 2.26.3 (#2087) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/changelog-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 97f0a86617..b15b2909bf 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -14,5 +14,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@3dc647fee3586e57c7c31eb900fdec7cbb44f23f # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@bae212ca7aec50bb716eafd387c80bcfb28da937 # v2 secrets: inherit From f28b3b0485881e3f35760fb94c6eb87526cdb9c8 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 2 Jun 2026 00:04:13 +0900 Subject: [PATCH 78/89] ref: update mago baseline (#2101) --- analysis-baseline.toml | 36 +++++++----------------------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/analysis-baseline.toml b/analysis-baseline.toml index f214ee80ed..e5e0ad56b5 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -294,12 +294,6 @@ code = "impossible-condition" message = "This condition (type `false`) will always evaluate to false." count = 2 -[[issues]] -file = "src/HttpClient/HttpClient.php" -code = "less-specific-argument" -message = "Argument type mismatch for argument #1 of `curl_share_setopt`: expected `CurlShareHandle`, but provided type `null|object|resource` is less specific." -count = 2 - [[issues]] file = "src/HttpClient/HttpClient.php" code = "mixed-argument" @@ -330,18 +324,6 @@ code = "mixed-operand" message = "Left operand in `>=` comparison has `mixed` type." count = 1 -[[issues]] -file = "src/HttpClient/HttpClient.php" -code = "mixed-property-type-coercion" -message = "A value with a less specific type `mixed` is being assigned to property `$$shareHandle` (null|object|resource)." -count = 1 - -[[issues]] -file = "src/HttpClient/HttpClient.php" -code = "non-existent-function" -message = 'Could not find definition for function `Sentry\HttpClient\curl_share_init_persistent` (also tried as `curl_share_init_persistent` in a broader scope).' -count = 1 - [[issues]] file = "src/HttpClient/HttpClient.php" code = "possibly-false-array-access" @@ -350,15 +332,17 @@ count = 1 [[issues]] file = "src/HttpClient/HttpClient.php" -code = "possibly-null-argument" -message = "Argument #1 of function `curl_share_setopt` is possibly `null`, but parameter type `CurlShareHandle` does not accept it." +code = "redundant-comparison" +message = "Redundant `<` comparison: left-hand side is never less than right-hand side." count = 2 [[issues]] +# false positive: function curl_share_init_persistent(array $share_options): CurlSharePersistentHandle +# https://www.php.net/manual/en/function.curl-share-init-persistent.php file = "src/HttpClient/HttpClient.php" -code = "redundant-comparison" -message = "Redundant `<` comparison: left-hand side is never less than right-hand side." -count = 2 +code = "too-many-arguments" +message = "Too many arguments provided for function `curl_share_init_persistent`." +count = 1 [[issues]] file = "src/HttpClient/Request.php" @@ -1242,12 +1226,6 @@ code = "null-operand" message = "Right operand in `!=` comparison is `null`." count = 1 -[[issues]] -file = "src/Serializer/AbstractSerializer.php" -code = "possibly-invalid-argument" -message = "Possible argument type mismatch for argument #1 of `ReflectionFunction::__construct`: expected `(closure(...mixed=): mixed)|string`, but possibly received `Closure`." -count = 1 - [[issues]] file = "src/Serializer/AbstractSerializer.php" code = "possibly-null-operand" From f6b6ae469d56956fc41f712ccdeef82689ba7a46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:13:02 +0200 Subject: [PATCH 79/89] chore(deps): bump getsentry/craft from 2.26.3 to 2.26.6 (#2098) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index c53aa1640e..1c789c6c69 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@bae212ca7aec50bb716eafd387c80bcfb28da937 + uses: getsentry/craft@3e6a0f477702864bb5854384b390a0db3325428e env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 1bdfa75d900c9e3119b873ef9157a1653a5bb695 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:21:25 +0200 Subject: [PATCH 80/89] chore(deps): bump getsentry/github-workflows from 24be69680c3a46c03edfbeb05268ec312f77e1e7 to c802283cd9075b7a2b7a32655019c21c21676e34 (#2097) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 09779e493e..c4e38e4d6c 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@24be69680c3a46c03edfbeb05268ec312f77e1e7 + - uses: getsentry/github-workflows/validate-pr@c802283cd9075b7a2b7a32655019c21c21676e34 with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 7aaa37d1899ff7ba9c9a9a706a32ce9f00b9a048 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:21:34 +0200 Subject: [PATCH 81/89] chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#2093) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63d455d871..0bc7e1d9e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: run: vendor/bin/phpunit --testsuite oom --no-coverage - name: Upload code coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} From c4dea44d2cc600cde3a4f7164870b0f223fe1d4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:21:43 +0200 Subject: [PATCH 82/89] chore(deps): bump actions/create-github-app-token from 3.1.1 to 3.2.0 (#2089) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 1c789c6c69..ec49c74cb1 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -24,7 +24,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From ec43955449ba611cfd50b3f0ef9bb7aa1b421b0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:21:57 +0200 Subject: [PATCH 83/89] chore(deps): bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.26.3 to 2.26.6 (#2096) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/changelog-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index b15b2909bf..c6dd82320b 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -14,5 +14,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@bae212ca7aec50bb716eafd387c80bcfb28da937 # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@3e6a0f477702864bb5854384b390a0db3325428e # v2 secrets: inherit From bd74d4f79286bb09fad9ebce1bc0f20c8afd44c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:22:17 +0200 Subject: [PATCH 84/89] chore(deps): bump shivammathur/setup-php from 2.37.0 to 2.37.1 (#2091) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/static-analysis.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bc7e1d9e4..4b513384c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: ${{ matrix.php.version }} coverage: xdebug @@ -120,7 +120,7 @@ jobs: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' coverage: none @@ -185,7 +185,7 @@ jobs: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' coverage: none diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 4d3e901111..bc5878781e 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' From 5a527c88ee98e93136015fc18b640af170bd7cee Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 2 Jun 2026 10:36:29 +0200 Subject: [PATCH 85/89] ref: remove SpanBench and phpbench dependency (#2100) --- .github/workflows/ci.yml | 4 --- composer.json | 1 - phpbench.json | 6 ---- tests/Benchmark/SpanBench.php | 62 ----------------------------------- 4 files changed, 73 deletions(-) delete mode 100644 phpbench.json delete mode 100644 tests/Benchmark/SpanBench.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b513384c8..04cffa8d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,10 +105,6 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} - - name: Check benchmarks - run: vendor/bin/phpbench run --revs=1 --iterations=1 - if: ${{ matrix.dependencies == 'highest' && matrix.php.version == '8.4' }} - runtime-tests-frankenphp: name: Runtime tests (FrankenPHP) runs-on: ubuntu-latest diff --git a/composer.json b/composer.json index cb1a2b3afb..4ba45bedab 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,6 @@ "open-telemetry/api": "^1.0", "open-telemetry/exporter-otlp": "^1.0", "open-telemetry/sdk": "^1.0", - "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", "phpunit/phpunit": "^8.5.52|^9.6.34", "spiral/roadrunner-http": "^3.6", diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index 3ae2778659..0000000000 --- a/phpbench.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php", - "runner.retry_threshold": 2, - "runner.path": "tests/Benchmark" -} diff --git a/tests/Benchmark/SpanBench.php b/tests/Benchmark/SpanBench.php deleted file mode 100644 index 0e0878d92c..0000000000 --- a/tests/Benchmark/SpanBench.php +++ /dev/null @@ -1,62 +0,0 @@ -context = continueTrace('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-0', ''); - $this->contextWithTimestamp = continueTrace('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-0', ''); - $this->contextWithTimestamp->setStartTimestamp(microtime(true)); - } - - /** - * @Revs(100000) - * - * @Iterations(10) - */ - public function benchConstructor(): void - { - $span = new Span(); - } - - /** - * @Revs(100000) - * - * @Iterations(10) - */ - public function benchConstructorWithInjectedContext(): void - { - $span = new Span($this->context); - } - - /** - * @Revs(100000) - * - * @Iterations(10) - */ - public function benchConstructorWithInjectedContextAndStartTimestamp(): void - { - $span = new Span($this->contextWithTimestamp); - } -} From d259da03cc8f753008f6f709f99f4a59b7c37d49 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 2 Jun 2026 11:00:47 +0200 Subject: [PATCH 86/89] feat(ci): add composer audit to CI pipeline (#2102) --- .github/workflows/ci.yml | 35 +++++++++++++++++++++----- .github/workflows/static-analysis.yaml | 24 +++++++++++++++--- composer.json | 1 - 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04cffa8d70..b2e08f16d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,14 +86,25 @@ jobs: - name: Set phpunit/phpunit version constraint run: composer require phpunit/phpunit:'${{ matrix.php.phpunit }}' --dev --no-interaction --no-update - - name: Install highest dependencies - run: composer update --no-progress --no-interaction --prefer-dist + - name: Resolve highest dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit if: ${{ matrix.dependencies == 'highest' }} - - name: Install lowest dependencies - run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest + - name: Resolve lowest dependencies + run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest --no-install --no-plugins --no-scripts --no-audit if: ${{ matrix.dependencies == 'lowest' }} + - name: Audit highest dependencies + run: composer audit --locked --no-interaction --format=table + if: ${{ matrix.dependencies == 'highest' }} + + - name: Audit lowest dependencies + run: composer audit --locked --no-interaction --format=table --abandoned=report + if: ${{ matrix.dependencies == 'lowest' }} + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts + - name: Run unit tests run: vendor/bin/phpunit --testsuite unit --coverage-clover=coverage.xml # The reason for running some OOM tests without coverage is that because the coverage information collector can cause another OOM event invalidating the test @@ -133,8 +144,14 @@ jobs: key: ${{ runner.os }}-runtime-frankenphp-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-runtime-frankenphp-composer- + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer install --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Install FrankenPHP env: @@ -198,8 +215,14 @@ jobs: key: ${{ runner.os }}-runtime-roadrunner-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-runtime-roadrunner-composer- + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer install --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Install RoadRunner env: diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index bc5878781e..e3da232c98 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -23,8 +23,14 @@ jobs: with: php-version: '8.4' + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer update --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Run script run: vendor/bin/php-cs-fixer fix --verbose --diff --dry-run @@ -41,8 +47,14 @@ jobs: with: php-version: '8.4' + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer update --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Run script run: vendor/bin/phpstan analyse @@ -59,8 +71,14 @@ jobs: with: php-version: '8.4' + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer update --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Run script run: composer mago diff --git a/composer.json b/composer.json index 4ba45bedab..e14884a578 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,6 @@ "carthage-software/mago": "^1.13.3", "friendsofphp/php-cs-fixer": "^3.4", "guzzlehttp/promises": "^2.0.3", - "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", "nyholm/psr7": "^1.8", "open-telemetry/api": "^1.0", From 38ce7c3d7c965af9b0221779902df3ff624874e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:23:35 +0200 Subject: [PATCH 87/89] chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 (#2103) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish-release.yaml | 2 +- .github/workflows/static-analysis.yaml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2e08f16d4..49052d375d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 2 @@ -122,7 +122,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 2 @@ -193,7 +193,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 2 diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index ec49c74cb1..dbf9b4e14a 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -29,7 +29,7 @@ jobs: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index e3da232c98..58b41ad154 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup PHP uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup PHP uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 @@ -64,7 +64,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup PHP uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 From 5413cccfd11621aaac5fe35213b2e70e43992383 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 10 Jun 2026 10:14:28 +0200 Subject: [PATCH 88/89] ci: pin mago and generate baseline to prevent random failures (#2111) --- analysis-baseline.toml | 22 +--------------------- composer.json | 2 +- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/analysis-baseline.toml b/analysis-baseline.toml index e5e0ad56b5..a75d873308 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -18,30 +18,12 @@ code = "implicit-to-string-cast" message = 'Implicit conversion to `string` for left operand via `Sentry\Severity::__toString()`.' count = 1 -[[issues]] -file = "src/Client.php" -code = "possible-method-access-on-null" -message = "Attempting to call a method on `null`." -count = 2 - [[issues]] file = "src/Client.php" code = "possibly-invalid-argument" message = '''Possible argument type mismatch for argument #1 of `Sentry\StacktraceBuilder::buildFromBacktrace`: expected `list, 'class'?: class-string, 'file'?: string, 'function'?: string, 'line'?: int, 'type'?: string}>`, but possibly received `array`.''' count = 1 -[[issues]] -file = "src/Client.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `Sentry\ExceptionDataBag::__construct` is possibly `null`, but parameter type `Throwable` does not accept it.' -count = 1 - -[[issues]] -file = "src/Client.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `Sentry\StacktraceBuilder::buildFromException` is possibly `null`, but parameter type `Throwable` does not accept it.' -count = 1 - [[issues]] file = "src/Client.php" code = "possibly-null-operand" @@ -337,8 +319,6 @@ message = "Redundant `<` comparison: left-hand side is never less than right-han count = 2 [[issues]] -# false positive: function curl_share_init_persistent(array $share_options): CurlSharePersistentHandle -# https://www.php.net/manual/en/function.curl-share-init-persistent.php file = "src/HttpClient/HttpClient.php" code = "too-many-arguments" message = "Too many arguments provided for function `curl_share_init_persistent`." @@ -1079,7 +1059,7 @@ count = 1 [[issues]] file = "src/Profiling/Profile.php" code = "invalid-return-statement" -message = '''Invalid return type for function `Sentry\Profiling\Profile::getFormattedData`: expected `array{'device': array{'architecture': string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': string}, 'platform': string, 'profile': array{'frames': array, 'samples': array, 'stacks': array>}, 'release': string, 'runtime': array{'name': string, 'version': string}, 'timestamp': string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': string, 'trace_id': string}, 'version': string}|null`, but found `array{'device': array{'architecture': null|string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': null|string}, 'platform': string('php'), 'profile': array{'frames': array{}|list, 'samples': array{}|non-empty-list, 'stacks': list>}, 'release': string, 'runtime': array{'name': string, 'sapi': null|string, 'version': null|string}, 'timestamp': string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': null|string, 'trace_id': null|string}, 'version': string}`.''' +message = '''Invalid return type for function `Sentry\Profiling\Profile::getFormattedData`: expected `array{'device': array{'architecture': string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': string}, 'platform': string, 'profile': array{'frames': array, 'samples': array, 'stacks': array>}, 'release': string, 'runtime': array{'name': string, 'version': string}, 'timestamp': string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': string, 'trace_id': string}, 'version': string}|null`, but found `array{'device': array{'architecture': null|string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': null|string}, 'platform': string('php'), 'profile': array{'frames': array{}|list, 'samples': array{}|non-empty-list, 'stacks': list>}, 'release': string, 'runtime': array{'name': string, 'sapi': null|string, 'version': null|string}, 'timestamp': non-empty-string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': null|string, 'trace_id': null|string}, 'version': string}`.''' count = 1 [[issues]] diff --git a/composer.json b/composer.json index e14884a578..a7554ac773 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" }, "require-dev": { - "carthage-software/mago": "^1.13.3", + "carthage-software/mago": "1.30.0", "friendsofphp/php-cs-fixer": "^3.4", "guzzlehttp/promises": "^2.0.3", "monolog/monolog": "^1.6|^2.0|^3.0", From 82f58402e36d630e323728cd3fe52560abfc3ee3 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 10 Jun 2026 21:46:55 +0200 Subject: [PATCH 89/89] fix --- analysis-baseline.toml | 42 ++++++++++++++++++++++++++++++++++++++ src/Logger/DebugLogger.php | 6 +++--- src/Unit.php | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/analysis-baseline.toml b/analysis-baseline.toml index eb9f0b7179..9513a4e36f 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -456,12 +456,54 @@ code = "mixed-assignment" message = "Assigning `mixed` type to a variable may lead to unexpected behavior." count = 2 +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugFileLogger::log()` expects type `string` but parent `Psr\Log\LoggerInterface::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugFileLogger::log()` expects type `string` but parent `Psr\Log\LoggerTrait::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugLogger::log()` expects type `string` but parent `Psr\Log\LoggerInterface::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugLogger::log()` expects type `string` but parent `Psr\Log\LoggerTrait::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugStdOutLogger::log()` expects type `string` but parent `Psr\Log\LoggerInterface::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugStdOutLogger::log()` expects type `string` but parent `Psr\Log\LoggerTrait::log()` expects type `Stringable|string`' +count = 1 + [[issues]] file = "src/Logger/DebugLogger.php" code = "mixed-argument" message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." count = 1 +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "redundant-cast" +message = "Redundant cast to `(string)`: the expression already has this type." +count = 1 + [[issues]] file = "src/Logs/LogsAggregator.php" code = "impossible-condition" diff --git a/src/Logger/DebugLogger.php b/src/Logger/DebugLogger.php index 7ebe021153..9b7a27583e 100644 --- a/src/Logger/DebugLogger.php +++ b/src/Logger/DebugLogger.php @@ -9,9 +9,9 @@ abstract class DebugLogger extends AbstractLogger { /** - * @param mixed $level - * @param string|\Stringable $message - * @param mixed[] $context + * @param mixed $level + * @param string $message + * @param mixed[] $context */ public function log($level, $message, array $context = []): void { diff --git a/src/Unit.php b/src/Unit.php index 5a83ab720b..9e4987ab36 100644 --- a/src/Unit.php +++ b/src/Unit.php @@ -4,7 +4,7 @@ namespace Sentry; -final class Unit implements \Stringable +final class Unit { /** * @var string The value of the enum instance