From 7ed64c110d0314a01aea92769bca19fc022f4aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Chav=C3=A9e?= Date: Mon, 20 Oct 2025 14:40:50 +0200 Subject: [PATCH 01/36] fix: displaying "encoding error" when serializing empty strings (#1940) --- src/Serializer/AbstractSerializer.php | 4 ++++ tests/Serializer/SerializerTest.php | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Serializer/AbstractSerializer.php b/src/Serializer/AbstractSerializer.php index 42946bf8d5..9e63123254 100644 --- a/src/Serializer/AbstractSerializer.php +++ b/src/Serializer/AbstractSerializer.php @@ -216,6 +216,10 @@ protected function serializeObject($object, int $_depth = 0, array $hashes = []) */ protected function serializeString(string $value): string { + if ($value === '') { + return ''; + } + // we always guarantee this is coerced, even if we can't detect encoding if ($currentEncoding = mb_detect_encoding($value, $this->mbDetectOrder)) { $encoded = mb_convert_encoding($value, 'UTF-8', $currentEncoding) ?: ''; diff --git a/tests/Serializer/SerializerTest.php b/tests/Serializer/SerializerTest.php index d402f210fa..3f06620272 100644 --- a/tests/Serializer/SerializerTest.php +++ b/tests/Serializer/SerializerTest.php @@ -225,6 +225,15 @@ public function testClippingUTF8Characters(): void $this->assertSame(\JSON_ERROR_NONE, json_last_error()); } + public function testEmptyString(): void + { + $serializer = $this->createSerializer(); + + $result = $this->invokeSerialization($serializer, ''); + + $this->assertSame('', $result); + } + /** * @return Serializer */ From 41b15c9c58f7eec9dde42f0c5dd1f30c49e42a76 Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Mon, 20 Oct 2025 14:44:14 +0200 Subject: [PATCH 02/36] fix(logs): `sentry.origin` value (#1938) --- src/Monolog/LogsHandler.php | 2 +- tests/Monolog/LogsHandlerTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Monolog/LogsHandler.php b/src/Monolog/LogsHandler.php index 6d6842edde..f96a6a5e7f 100644 --- a/src/Monolog/LogsHandler.php +++ b/src/Monolog/LogsHandler.php @@ -66,7 +66,7 @@ public function handle($record): bool self::getSentryLogLevelFromMonologLevel($record['level']), $record['message'], [], - array_merge($record['context'], $record['extra'], ['sentry.origin' => 'auto.logger.monolog']) + array_merge($record['context'], $record['extra'], ['sentry.origin' => 'auto.log.monolog']) ); return $this->bubble === false; diff --git a/tests/Monolog/LogsHandlerTest.php b/tests/Monolog/LogsHandlerTest.php index 20d69ec9af..2a6af32982 100644 --- a/tests/Monolog/LogsHandlerTest.php +++ b/tests/Monolog/LogsHandlerTest.php @@ -129,7 +129,7 @@ public function testOriginTagAppliedWithHandler(): void $this->assertCount(1, $logs); $log = $logs[0]; $this->assertArrayHasKey('sentry.origin', $log->attributes()->toSimpleArray()); - $this->assertSame('auto.logger.monolog', $log->attributes()->toSimpleArray()['sentry.origin']); + $this->assertSame('auto.log.monolog', $log->attributes()->toSimpleArray()['sentry.origin']); } public function testOriginTagNotAppliedWhenUsingDirectly() From 945f863abd98f26e241ff10260d7c92518b3a725 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 20 Oct 2025 14:56:25 +0200 Subject: [PATCH 03/36] Prepare 4.17.0 (#1941) --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7710789d09..53119b8a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGELOG +## 4.17.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.17.0. + +### Bug Fixes + +- Empty strings will no longer display `` when serialized. [(#1940)](https://github.com/getsentry/sentry-php/pull/1940) + +### Misc + +- Replace polyfill methods with PHP 7.2 equivalents. [(#1922)](https://github.com/getsentry/sentry-php/pull/1922) +- Remove `symfony/phpunit-bridge` as a dev dependency. [(#1930)](https://github.com/getsentry/sentry-php/pull/1930) +- Update `sentry.origin` to be consistent with other SDKs. [(#1938)](https://github.com/getsentry/sentry-php/pull/1938) + ## 4.16.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.16.0. From 62927369a572efc27ddbd89e466e17788329224b Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 20 Oct 2025 12:57:02 +0000 Subject: [PATCH 04/36] release: 4.17.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 1c51e6de28..962a8bdb64 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.16.0'; + public const SDK_VERSION = '4.17.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From c81a4db74f867f006a872a19648916c2be2cbab2 Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Tue, 21 Oct 2025 11:09:56 +0200 Subject: [PATCH 05/36] Remove changelog entry for unreleased feature (#1943) --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53119b8a0b..646fbedfaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ The Sentry SDK team is happy to announce the immediate availability of Sentry PH ### Misc -- Replace polyfill methods with PHP 7.2 equivalents. [(#1922)](https://github.com/getsentry/sentry-php/pull/1922) - Remove `symfony/phpunit-bridge` as a dev dependency. [(#1930)](https://github.com/getsentry/sentry-php/pull/1930) - Update `sentry.origin` to be consistent with other SDKs. [(#1938)](https://github.com/getsentry/sentry-php/pull/1938) From 775c7accd7c0e62b5ac56aa9a8bbfec20cfc55db Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Oct 2025 15:00:18 +0200 Subject: [PATCH 06/36] ref: change doc to use `never` instead of `none` (#1944) --- src/Options.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Options.php b/src/Options.php index 28093ed6e6..84ead78367 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1130,7 +1130,7 @@ public function getMaxRequestBodySize(): string * captured. It can be set to one of the * following values: * - * - none: request bodies are never sent + * - never: request bodies are never sent * - small: only small request bodies will * be captured where the cutoff for small * depends on the SDK (typically 4KB) From 4885d13a47ab9e2cb9f89ce2e3d4f781246eda5a Mon Sep 17 00:00:00 2001 From: Jamie Burchell Date: Thu, 23 Oct 2025 15:37:16 +0100 Subject: [PATCH 07/36] fix: Only call curl_close() in PHP < 8 (#1947) --- src/HttpClient/HttpClient.php | 8 ++++++-- src/Spotlight/SpotlightClient.php | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index aa5873df3a..2a373d9300 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -106,7 +106,9 @@ public function sendRequest(Request $request, Options $options): Response if ($body === false) { $errorCode = curl_errno($curlHandle); $error = curl_error($curlHandle); - curl_close($curlHandle); + if (\PHP_MAJOR_VERSION < 8) { + curl_close($curlHandle); + } $message = 'cURL Error (' . $errorCode . ') ' . $error; @@ -115,7 +117,9 @@ public function sendRequest(Request $request, Options $options): Response $statusCode = curl_getinfo($curlHandle, \CURLINFO_HTTP_CODE); - curl_close($curlHandle); + if (\PHP_MAJOR_VERSION < 8) { + curl_close($curlHandle); + } $error = $statusCode >= 400 ? $body : ''; diff --git a/src/Spotlight/SpotlightClient.php b/src/Spotlight/SpotlightClient.php index f32af4cda4..0b56517b3c 100644 --- a/src/Spotlight/SpotlightClient.php +++ b/src/Spotlight/SpotlightClient.php @@ -42,7 +42,9 @@ public static function sendRequest(Request $request, string $url): Response if ($body === false) { $errorCode = curl_errno($curlHandle); $error = curl_error($curlHandle); - curl_close($curlHandle); + if (\PHP_MAJOR_VERSION < 8) { + curl_close($curlHandle); + } $message = 'cURL Error (' . $errorCode . ') ' . $error; @@ -51,7 +53,9 @@ public static function sendRequest(Request $request, string $url): Response $statusCode = curl_getinfo($curlHandle, \CURLINFO_HTTP_CODE); - curl_close($curlHandle); + if (\PHP_MAJOR_VERSION < 8) { + curl_close($curlHandle); + } return new Response($statusCode, [], ''); } From ebbfb6ad3ca6535b30f193fb408d52b418330673 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 23 Oct 2025 17:18:34 +0200 Subject: [PATCH 08/36] Prepare 4.17.1 (#1948) Co-authored-by: Michi Hoffmann --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 646fbedfaa..503487953a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 4.17.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.17.1. + +### Misc + +- Call `curl_close` only on PHP version 7.4 and below to prevent deprecation warnings. [(#1947)](https://github.com/getsentry/sentry-php/pull/1947) + ## 4.17.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.17.0. From 5c696b8de57e841a2bf3b6f6eecfd99acfdda80c Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 23 Oct 2025 15:19:24 +0000 Subject: [PATCH 09/36] release: 4.17.1 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 962a8bdb64..46f142e1c6 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.17.0'; + public const SDK_VERSION = '4.17.1'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 6df603de4453f9eb8253c9fcc34bf7795954e33b Mon Sep 17 00:00:00 2001 From: Michi Hoffmann Date: Tue, 28 Oct 2025 11:29:26 -0400 Subject: [PATCH 10/36] Add support for Symfony 8 (#1895) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 40e58869ba..6078911415 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "guzzlehttp/psr7": "^1.8.4|^2.1.1", "jean85/pretty-package-versions": "^1.5|^2.0.4", "psr/log": "^1.0|^2.0|^3.0", - "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0" + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.4", From 8e24c116c5017111c06b78780b33a7a2e011c0da Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Tue, 4 Nov 2025 10:00:41 +0100 Subject: [PATCH 11/36] feat: feature flags (#1951) --- src/State/Scope.php | 53 +++++++++++++++++++++ src/Tracing/Span.php | 34 +++++++++++++- tests/State/ScopeTest.php | 94 ++++++++++++++++++++++++++++++++++++++ tests/Tracing/SpanTest.php | 30 ++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) diff --git a/src/State/Scope.php b/src/State/Scope.php index e4e054c3c6..7d3736c421 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -21,6 +21,13 @@ */ class Scope { + /** + * Maximum number of flags allowed. We only track the first flags set. + * + * @internal + */ + public const MAX_FLAGS = 100; + /** * @var PropagationContext */ @@ -46,6 +53,11 @@ class Scope */ private $tags = []; + /** + * @var array> The list of flags associated to this scope + */ + private $flags = []; + /** * @var array A set of extra data associated to this scope */ @@ -130,6 +142,35 @@ public function removeTag(string $key): self return $this; } + /** + * Adds a feature flag to the scope. + * + * @return $this + */ + public function addFeatureFlag(string $key, bool $result): self + { + // If the flag was already set, remove it first + // This basically mimics an LRU cache so that the most recently added flags are kept + foreach ($this->flags as $flagIndex => $flag) { + if (isset($flag[$key])) { + unset($this->flags[$flagIndex]); + } + } + + // Keep only the most recent MAX_FLAGS flags + if (\count($this->flags) >= self::MAX_FLAGS) { + array_shift($this->flags); + } + + $this->flags[] = [$key => $result]; + + if ($this->span !== null) { + $this->span->setFlag($key, $result); + } + + return $this; + } + /** * Sets data to the context by a given name. * @@ -331,6 +372,7 @@ public function clear(): self $this->fingerprint = []; $this->breadcrumbs = []; $this->tags = []; + $this->flags = []; $this->extra = []; $this->contexts = []; @@ -359,6 +401,17 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op $event->setTags(array_merge($this->tags, $event->getTags())); } + if (!empty($this->flags)) { + $event->setContext('flags', [ + 'values' => array_map(static function (array $flag) { + return [ + 'flag' => key($flag), + 'result' => current($flag), + ]; + }, $this->flags), + ]); + } + if (!empty($this->extra)) { $event->setExtra(array_merge($this->extra, $event->getExtra())); } diff --git a/src/Tracing/Span.php b/src/Tracing/Span.php index 51308b06e6..e55c4c9487 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -22,6 +22,13 @@ */ class Span { + /** + * Maximum number of flags allowed. We only track the first flags set. + * + * @internal + */ + public const MAX_FLAGS = 10; + /** * @var SpanId Span ID */ @@ -62,6 +69,11 @@ class Span */ protected $tags = []; + /** + * @var array A List of flags associated to this span + */ + protected $flags = []; + /** * @var array An arbitrary mapping of additional metadata */ @@ -328,6 +340,20 @@ public function setTags(array $tags) return $this; } + /** + * Sets a feature flag associated to this span. + * + * @return $this + */ + public function setFlag(string $key, bool $result) + { + if (\count($this->flags) < self::MAX_FLAGS) { + $this->flags[$key] = $result; + } + + return $this; + } + /** * Gets the ID of the span. */ @@ -369,7 +395,13 @@ public function setSampled(?bool $sampled) public function getData(?string $key = null, $default = null) { if ($key === null) { - return $this->data; + $data = $this->data; + + foreach ($this->flags as $flagKey => $flagValue) { + $data["flag.evaluation.{$flagKey}"] = $flagValue; + } + + return $data; } return $this->data[$key] ?? $default; diff --git a/tests/State/ScopeTest.php b/tests/State/ScopeTest.php index caf6b3fd7f..281cbadb93 100644 --- a/tests/State/ScopeTest.php +++ b/tests/State/ScopeTest.php @@ -12,6 +12,7 @@ use Sentry\State\Scope; use Sentry\Tracing\DynamicSamplingContext; use Sentry\Tracing\PropagationContext; +use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; @@ -77,6 +78,88 @@ public function testRemoveTag(): void $this->assertSame(['bar' => 'baz'], $event->getTags()); } + public function testSetFlag(): void + { + $scope = new Scope(); + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayNotHasKey('flags', $event->getContexts()); + + $scope->addFeatureFlag('foo', true); + $scope->addFeatureFlag('bar', false); + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayHasKey('flags', $event->getContexts()); + $this->assertEquals([ + 'values' => [ + [ + 'flag' => 'foo', + 'result' => true, + ], + [ + 'flag' => 'bar', + 'result' => false, + ], + ], + ], $event->getContexts()['flags']); + } + + public function testSetFlagLimit(): void + { + $scope = new Scope(); + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayNotHasKey('flags', $event->getContexts()); + + $expectedFlags = []; + + foreach (range(1, Scope::MAX_FLAGS) as $i) { + $scope->addFeatureFlag("feature{$i}", true); + + $expectedFlags[] = [ + 'flag' => "feature{$i}", + 'result' => true, + ]; + } + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayHasKey('flags', $event->getContexts()); + $this->assertEquals(['values' => $expectedFlags], $event->getContexts()['flags']); + + array_shift($expectedFlags); + + $scope->addFeatureFlag('should-not-be-discarded', true); + + $expectedFlags[] = [ + 'flag' => 'should-not-be-discarded', + 'result' => true, + ]; + + $event = $scope->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertArrayHasKey('flags', $event->getContexts()); + $this->assertEquals(['values' => $expectedFlags], $event->getContexts()['flags']); + } + + public function testSetFlagPropagatesToSpan(): void + { + $span = new Span(); + + $scope = new Scope(); + $scope->setSpan($span); + + $scope->addFeatureFlag('feature', true); + + $this->assertSame(['flag.evaluation.feature' => true], $span->getData()); + } + public function testSetAndRemoveContext(): void { $propgationContext = PropagationContext::fromDefaults(); @@ -364,6 +447,7 @@ public function testClear(): void $scope->setFingerprint(['foo']); $scope->setExtras(['foo' => 'bar']); $scope->setTags(['bar' => 'foo']); + $scope->addFeatureFlag('feature', true); $scope->setUser(UserDataBag::createFromUserIdentifier('unique_id')); $scope->clear(); @@ -376,6 +460,7 @@ public function testClear(): void $this->assertEmpty($event->getExtra()); $this->assertEmpty($event->getTags()); $this->assertEmpty($event->getUser()); + $this->assertArrayNotHasKey('flags', $event->getContexts()); } public function testApplyToEvent(): void @@ -403,6 +488,7 @@ public function testApplyToEvent(): void $scope->setUser($user); $scope->setContext('foocontext', ['foo' => 'bar']); $scope->setContext('barcontext', ['bar' => 'foo']); + $scope->addFeatureFlag('feature', true); $scope->setSpan($span); $this->assertSame($event, $scope->applyToEvent($event)); @@ -417,6 +503,14 @@ public function testApplyToEvent(): void 'foo' => 'foo', 'bar' => 'bar', ], + 'flags' => [ + 'values' => [ + [ + 'flag' => 'feature', + 'result' => true, + ], + ], + ], 'trace' => [ 'span_id' => '566e3688a61d4bc8', 'trace_id' => '566e3688a61d4bc888951642d6f14a19', diff --git a/tests/Tracing/SpanTest.php b/tests/Tracing/SpanTest.php index 987feffb53..e0db7c6d58 100644 --- a/tests/Tracing/SpanTest.php +++ b/tests/Tracing/SpanTest.php @@ -187,4 +187,34 @@ public function testOriginIsCopiedFromContext(): void $this->assertSame($context->getOrigin(), $span->getOrigin()); $this->assertSame($context->getOrigin(), $span->getTraceContext()['origin']); } + + public function testFlagIsRecorded(): void + { + $span = new Span(); + + $span->setFlag('feature', true); + + $this->assertSame(['flag.evaluation.feature' => true], $span->getData()); + } + + public function testFlagLimitRecorded(): void + { + $span = new Span(); + + $expectedFlags = [ + 'flag.evaluation.should-not-be-discarded' => true, + ]; + + $span->setFlag('should-not-be-discarded', true); + + foreach (range(1, Span::MAX_FLAGS - 1) as $i) { + $span->setFlag("feature{$i}", true); + + $expectedFlags["flag.evaluation.feature{$i}"] = true; + } + + $span->setFlag('should-be-discarded', true); + + $this->assertSame($expectedFlags, $span->getData()); + } } From 6cad7b2f01a262486a5a4b6ba81fa589a75addf2 Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Tue, 4 Nov 2025 10:01:24 +0100 Subject: [PATCH 12/36] feat: try to represent any logs attribute as string (#1950) --- src/Attributes/Attribute.php | 25 +++++++++++----- src/Logs/LogsAggregator.php | 4 +-- tests/Attributes/AttributeTest.php | 48 +++++++++++++++++++++++++++--- tests/Logs/LogsAggregatorTest.php | 2 +- tests/Logs/LogsTest.php | 7 ++++- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/Attributes/Attribute.php b/src/Attributes/Attribute.php index ce792220eb..3a79c84c5a 100644 --- a/src/Attributes/Attribute.php +++ b/src/Attributes/Attribute.php @@ -4,6 +4,9 @@ namespace Sentry\Attributes; +use Sentry\Serializer\SerializableInterface; +use Sentry\Util\JSON; + /** * @phpstan-type AttributeType 'string'|'boolean'|'integer'|'double' * @phpstan-type AttributeValue string|bool|int|float @@ -68,7 +71,7 @@ public static function fromValue($value): self public static function tryFromValue($value): ?self { if ($value === null) { - return null; + return new self('null', 'string'); } if (\is_bool($value)) { @@ -83,14 +86,22 @@ public static function tryFromValue($value): ?self return new self($value, 'double'); } - if (\is_string($value) || (\is_object($value) && method_exists($value, '__toString'))) { - $stringValue = (string) $value; - - if (empty($stringValue)) { - return null; + if ($value instanceof SerializableInterface) { + try { + return new self(JSON::encode($value->toSentry()), 'string'); + } catch (\Throwable $e) { + // Ignore the exception and continue trying other methods } + } + + if (\is_string($value) || (\is_object($value) && method_exists($value, '__toString'))) { + return new self((string) $value, 'string'); + } - return new self($stringValue, 'string'); + try { + return new self(JSON::encode($value), 'string'); + } catch (\Throwable $e) { + // Ignore the exception } return null; diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index d17b04939c..e2fb5ea781 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -109,8 +109,6 @@ public function add( $attributes = Arr::simpleDot($attributes); foreach ($attributes as $key => $value) { - $attribute = Attribute::tryFromValue($value); - if (!\is_string($key)) { if ($sdkLogger !== null) { $sdkLogger->info( @@ -121,6 +119,8 @@ public function add( continue; } + $attribute = Attribute::tryFromValue($value); + if ($attribute === null) { if ($sdkLogger !== null) { $sdkLogger->info( diff --git a/tests/Attributes/AttributeTest.php b/tests/Attributes/AttributeTest.php index 030f628bda..1cde9a4bf6 100644 --- a/tests/Attributes/AttributeTest.php +++ b/tests/Attributes/AttributeTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Sentry\Attributes\Attribute; +use Sentry\Serializer\SerializableInterface; /** * @phpstan-import-type AttributeType from Attribute @@ -43,6 +44,14 @@ public static function fromValueDataProvider(): \Generator ], ]; + yield [ + '', + [ + 'type' => 'string', + 'value' => '', + ], + ]; + yield [ 123, [ @@ -67,6 +76,14 @@ public static function fromValueDataProvider(): \Generator ], ]; + yield [ + null, + [ + 'type' => 'string', + 'value' => 'null', + ], + ]; + yield [ new class { public function __toString(): string @@ -80,19 +97,41 @@ public function __toString(): string ], ]; + yield [ + new class implements SerializableInterface { + public function toSentry(): ?array + { + return ['foo' => 'bar']; + } + }, + [ + 'type' => 'string', + 'value' => '{"foo":"bar"}', + ], + ]; + yield [ new class {}, - null, + [ + 'type' => 'string', + 'value' => '{}', + ], ]; yield [ new \stdClass(), - null, + [ + 'type' => 'string', + 'value' => '{}', + ], ]; yield [ [], - null, + [ + 'type' => 'string', + 'value' => '[]', + ], ]; } @@ -112,6 +151,7 @@ public function testFromValueFactoryMethod(): void { $this->expectException(\InvalidArgumentException::class); - Attribute::fromValue([]); + // Since we support almost any type, we use a resource to trigger the exception + Attribute::fromValue(fopen(__FILE__, 'r')); } } diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index 377b9deff8..83a91e1da0 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -78,7 +78,7 @@ public static function attributesDataProvider(): \Generator yield [ ['foo' => ['bar']], - [], + ['foo' => '["bar"]'], ]; } diff --git a/tests/Logs/LogsTest.php b/tests/Logs/LogsTest.php index 1917bd48b0..3aab97419f 100644 --- a/tests/Logs/LogsTest.php +++ b/tests/Logs/LogsTest.php @@ -119,12 +119,17 @@ public function testLogWithNestedAttributes(): void $this->assertNotNull($attribute); $this->assertEquals('bar', $attribute->getValue()); + + $attribute = $logItem->attributes()->get('nested.baz'); + + $this->assertNotNull($attribute); + $this->assertEquals(json_encode([1, 2, 3]), $attribute->getValue()); }); logger()->info('Some message', [], [ 'nested' => [ 'foo' => 'bar', - 'should-be-missing' => [1, 2, 3], + 'baz' => [1, 2, 3], ], ]); From 01de10badeba858763be596fa592c54bac6caa63 Mon Sep 17 00:00:00 2001 From: Deeka Wong Date: Wed, 5 Nov 2025 21:38:23 +0800 Subject: [PATCH 13/36] ref(logs): extract attribute compilation to dedicated method in LogsHandler (#1931) Co-authored-by: Deeka Wong <8337659+huangdijia@users.noreply.github.com> --- src/Monolog/LogsHandler.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Monolog/LogsHandler.php b/src/Monolog/LogsHandler.php index f96a6a5e7f..9ee342a4fc 100644 --- a/src/Monolog/LogsHandler.php +++ b/src/Monolog/LogsHandler.php @@ -66,7 +66,7 @@ public function handle($record): bool self::getSentryLogLevelFromMonologLevel($record['level']), $record['message'], [], - array_merge($record['context'], $record['extra'], ['sentry.origin' => 'auto.log.monolog']) + $this->compileAttributes($record) ); return $this->bubble === false; @@ -123,4 +123,14 @@ public function __destruct() // Just in case so that the destructor can never fail. } } + + /** + * @param array|LogRecord $record + * + * @return array + */ + protected function compileAttributes($record): array + { + return array_merge($record['context'], $record['extra'], ['sentry.origin' => 'auto.log.monolog']); + } } From 0e9d3090b64b487296da61b94cd506f303726168 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 5 Nov 2025 15:09:25 +0100 Subject: [PATCH 14/36] Prepare 4.18.0 (#1958) --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 503487953a..0bff8e9c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # CHANGELOG +## 4.18.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.18.0. + +### Features + +- Add support for feature flags. [(#1951)](https://github.com/getsentry/sentry-php/pull/1951) +```php +\Sentry\SentrySdk::getCurrentHub()->configureScope(function (\Sentry\State\Scope $scope) { + $scope->addFeatureFlag("my.feature.enabled", true); +}); +``` +- Add more representations for log attributes instead of dropping them. [(#1950)](https://github.com/getsentry/sentry-php/pull/1950) + +### Misc + +- Merge log attributes in a separate method. [(#1931)](https://github.com/getsentry/sentry-php/pull/1931) + ## 4.17.1 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.17.1. From 75f7efb7d435d24767c93d0081b8edf228be5772 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 5 Nov 2025 14:37:07 +0000 Subject: [PATCH 15/36] release: 4.18.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 46f142e1c6..d7950fb762 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.17.1'; + public const SDK_VERSION = '4.18.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 60486ee1061aaf1901f76c18a9d5860cb146b2a0 Mon Sep 17 00:00:00 2001 From: Michi Hoffmann Date: Thu, 6 Nov 2025 15:28:20 +0100 Subject: [PATCH 16/36] meta: Run CI on PHP 8.5 (#1927) Co-authored-by: Martin Linzmayer --- .github/workflows/ci.yml | 1 + ...ry_fatal_error_increases_memory_limit.phpt | 5 ++ ...ry_fatal_error_increases_memory_limit.phpt | 84 +++++++++++++++++++ .../error_handler_captures_fatal_error.phpt | 5 ++ ...er_captures_out_of_memory_fatal_error.phpt | 5 ++ ...rror_integration_captures_fatal_error.phpt | 5 ++ ...tegration_respects_error_types_option.phpt | 5 ++ .../error_handler_captures_fatal_error.phpt | 80 ++++++++++++++++++ ...er_captures_out_of_memory_fatal_error.phpt | 46 ++++++++++ ...rror_integration_captures_fatal_error.phpt | 69 +++++++++++++++ ...tegration_respects_error_types_option.phpt | 69 +++++++++++++++ 11 files changed, 374 insertions(+) rename tests/phpt-oom/{ => php84}/out_of_memory_fatal_error_increases_memory_limit.phpt (95%) create mode 100644 tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt rename tests/phpt/{ => php84}/error_handler_captures_fatal_error.phpt (94%) rename tests/phpt/{ => php84}/error_handler_captures_out_of_memory_fatal_error.phpt (90%) rename tests/phpt/{ => php84}/fatal_error_integration_captures_fatal_error.phpt (93%) rename tests/phpt/{ => php84}/fatal_error_integration_respects_error_types_option.phpt (93%) create mode 100644 tests/phpt/php85/error_handler_captures_fatal_error.phpt create mode 100644 tests/phpt/php85/error_handler_captures_out_of_memory_fatal_error.phpt create mode 100644 tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt create mode 100644 tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dee415f48..e4921fc52e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: - { version: '8.2', phpunit: '^9.6.21' } - { version: '8.3', phpunit: '^9.6.21' } - { version: '8.4', phpunit: '^9.6.21' } + - { version: '8.5', phpunit: '^9.6.25' } dependencies: - lowest - highest diff --git a/tests/phpt-oom/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt similarity index 95% rename from tests/phpt-oom/out_of_memory_fatal_error_increases_memory_limit.phpt rename to tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt index acef1edd94..321ed418a1 100644 --- a/tests/phpt-oom/out_of_memory_fatal_error_increases_memory_limit.phpt +++ b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -1,5 +1,10 @@ --TEST-- Test that when handling a out of memory error the memory limit is increased with 5 MiB and the event is serialized and ready to be sent +--SKIPIF-- += 80400) { + die('skip - only works for PHP 8.4 and below'); +} --INI-- memory_limit=67108864 --FILE-- 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 new file mode 100644 index 0000000000..92e0305881 --- /dev/null +++ b/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -0,0 +1,84 @@ +--TEST-- +Test that when handling a out of memory error the memory limit is increased with 5 MiB and the event is serialized and ready to be sent +--SKIPIF-- + 'http://public@example.com/sentry/1', +]); + +$transport = new class(new PayloadSerializer($options)) implements TransportInterface { + private $payloadSerializer; + + public function __construct(PayloadSerializerInterface $payloadSerializer) + { + $this->payloadSerializer = $payloadSerializer; + } + + public function send(Event $event): Result + { + $serialized = $this->payloadSerializer->serialize($event); + + echo 'Transport called' . \PHP_EOL; + + return new Result(ResultStatus::success()); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } +}; + +$options->setTransport($transport); + +$client = (new ClientBuilder($options))->getClient(); + +SentrySdk::init()->bindClient($client); + +echo 'Before OOM memory limit: ' . \ini_get('memory_limit'); + +register_shutdown_function(function () { + echo 'After OOM memory limit: ' . \ini_get('memory_limit'); +}); + +$array = []; +for ($i = 0; $i < 100000000; ++$i) { + $array[] = 'sentry'; +} +--EXPECTF-- +Before OOM memory limit: 67108864 +Fatal error: Allowed memory size of %d bytes exhausted (tried to allocate %d bytes) in %s on line %d +Stack trace: +%A +Transport called +After OOM memory limit: 72351744 diff --git a/tests/phpt/error_handler_captures_fatal_error.phpt b/tests/phpt/php84/error_handler_captures_fatal_error.phpt similarity index 94% rename from tests/phpt/error_handler_captures_fatal_error.phpt rename to tests/phpt/php84/error_handler_captures_fatal_error.phpt index e7b457d2b6..2c4b9143bd 100644 --- a/tests/phpt/error_handler_captures_fatal_error.phpt +++ b/tests/phpt/php84/error_handler_captures_fatal_error.phpt @@ -1,5 +1,10 @@ --TEST-- Test catching fatal errors +--SKIPIF-- += 80500) { + die('skip - only works for PHP 8.4 and below'); +} --FILE-- = 80500) { + die('skip - only works for PHP 8.4 and below'); +} --INI-- memory_limit=67108864 --FILE-- diff --git a/tests/phpt/fatal_error_integration_captures_fatal_error.phpt b/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt similarity index 93% rename from tests/phpt/fatal_error_integration_captures_fatal_error.phpt rename to tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt index ba82e9bcaa..2c8e40c4d7 100644 --- a/tests/phpt/fatal_error_integration_captures_fatal_error.phpt +++ b/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt @@ -1,5 +1,10 @@ --TEST-- Test that the FatalErrorListenerIntegration integration captures only the errors allowed by the error_types option +--SKIPIF-- += 80500) { + die('skip - only works for PHP 8.4 and below'); +} --FILE-- = 80500) { + die('skip - only works for PHP 8.4 and below'); +} --FILE-- 'http://public@example.com/sentry/1', +]; + +$client = ClientBuilder::create($options) + ->setTransport($transport) + ->getClient(); + +SentrySdk::getCurrentHub()->bindClient($client); + +$errorHandler = ErrorHandler::registerOnceErrorHandler(); +$errorHandler->addErrorHandlerListener(static function (): void { + echo 'Error listener called (it should not have been)' . PHP_EOL; +}); + +$errorHandler = ErrorHandler::registerOnceFatalErrorHandler(); +$errorHandler->addFatalErrorHandlerListener(static function (): void { + echo 'Fatal error listener called' . PHP_EOL; +}); + +$errorHandler = ErrorHandler::registerOnceExceptionHandler(); +$errorHandler->addExceptionHandlerListener(static function (): void { + echo 'Exception listener called (it should not have been)' . PHP_EOL; +}); + +final class TestClass implements \JsonSerializable +{ +} +?> +--EXPECTF-- +Fatal error: Class Sentry\Tests\TestClass contains 1 abstract method and must therefore be declared abstract or implement the remaining method (JsonSerializable::jsonSerialize) in %s on line %d +Stack trace: +%A +Transport called +Fatal error listener called diff --git a/tests/phpt/php85/error_handler_captures_out_of_memory_fatal_error.phpt b/tests/phpt/php85/error_handler_captures_out_of_memory_fatal_error.phpt new file mode 100644 index 0000000000..cd3986d9c7 --- /dev/null +++ b/tests/phpt/php85/error_handler_captures_out_of_memory_fatal_error.phpt @@ -0,0 +1,46 @@ +--TEST-- +Test catching out of memory fatal error without increasing memory limit +--SKIPIF-- +addFatalErrorHandlerListener(static function (): void { + echo 'Fatal error listener called' . PHP_EOL; + + echo 'After OOM memory limit: ' . ini_get('memory_limit'); +}); + +$errorHandler->setMemoryLimitIncreaseOnOutOfMemoryErrorInBytes(null); + +echo 'Before OOM memory limit: ' . ini_get('memory_limit'); + +$foo = str_repeat('x', 1024 * 1024 * 1024); +?> +--EXPECTF-- +Before OOM memory limit: 67108864 +Fatal error: Allowed memory size of %d bytes exhausted (tried to allocate %d bytes) in %s on line %d +Stack trace: +%A +Fatal error listener called +After OOM memory limit: 67108864 diff --git a/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt b/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt new file mode 100644 index 0000000000..88fdd8f5cc --- /dev/null +++ b/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt @@ -0,0 +1,69 @@ +--TEST-- +Test that the FatalErrorListenerIntegration integration captures only the errors allowed by the error_types option +--SKIPIF-- + false, + 'integrations' => [ + new FatalErrorListenerIntegration(), + ], +]); + +$client = (new ClientBuilder($options)) + ->setTransport($transport) + ->getClient(); + +SentrySdk::getCurrentHub()->bindClient($client); + +final class TestClass implements \JsonSerializable +{ +} +?> +--EXPECTF-- +Fatal error: Class Sentry\Tests\TestClass contains 1 abstract method and must therefore be declared abstract or implement the remaining method (JsonSerializable::jsonSerialize) in %s on line %d +Stack trace: +%A +Transport called 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 new file mode 100644 index 0000000000..7d74233ce5 --- /dev/null +++ b/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt @@ -0,0 +1,69 @@ +--TEST-- +Test that the FatalErrorListenerIntegration integration captures only the errors allowed by the error_types option +--SKIPIF-- + E_ALL & ~E_ERROR, + 'default_integrations' => false, + 'integrations' => [ + new FatalErrorListenerIntegration(), + ], +]); + +$client = (new ClientBuilder($options)) + ->setTransport($transport) + ->getClient(); + +SentrySdk::getCurrentHub()->bindClient($client); + +final class TestClass implements \JsonSerializable +{ +} +?> +--EXPECTF-- +Fatal error: Class Sentry\Tests\TestClass contains 1 abstract method and must therefore be declared abstract or implement the remaining method (JsonSerializable::jsonSerialize) in %s on line %d +Stack trace: +%A From 7797f856cd6bdba357de6f8061b0c2271fca6bdc Mon Sep 17 00:00:00 2001 From: Michi Hoffmann Date: Fri, 7 Nov 2025 08:29:07 +0100 Subject: [PATCH 17/36] chore: Update X handle (#1961) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b8c54e21e..b46be953d9 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ If you need help setting up or configuring the PHP SDK (or anything else in the - [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/quickstart/) - [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) - [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](http://stackoverflow.com/questions/tagged/sentry) -- [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) +- [![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry) ## License From 8973b70953891e229cbaa0edeb48ae98bc1e7ee9 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 10 Nov 2025 12:55:44 +0100 Subject: [PATCH 18/36] ref: add `addFeatureFlag` helper function (#1960) --- src/functions.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/functions.php b/src/functions.php index 23f6eb1e1c..c013498218 100644 --- a/src/functions.php +++ b/src/functions.php @@ -379,3 +379,14 @@ function metrics(): Metrics { return Metrics::getInstance(); } + +/** + * Adds a feature flag evaluation to the current scope. + * When invoked repeatedly for the same name, the most recent value is used. + */ +function addFeatureFlag(string $name, bool $result): void +{ + SentrySdk::getCurrentHub()->configureScope(function (Scope $scope) use ($name, $result) { + $scope->addFeatureFlag($name, $result); + }); +} From afb6cb582c16516c51235d4ea91cced4e0df3c92 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 11 Nov 2025 10:34:15 +0100 Subject: [PATCH 19/36] Prepare 4.18.1 (#1965) --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bff8e9c90..be545ceea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +## 4.18.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.18.1. + +### Misc + +- Add `addFeatureFlag` helper function. [(#1960)](https://github.com/getsentry/sentry-php/pull/1960) +```php +\Sentry\addFeatureFlag("my.feature.enabled", true); +``` + ## 4.18.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.18.0. From 04dcf20b39742b731b676f8b8d4f02d1db488af8 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 11 Nov 2025 09:34:53 +0000 Subject: [PATCH 20/36] release: 4.18.1 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index d7950fb762..74c8cfa779 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.18.0'; + public const SDK_VERSION = '4.18.1'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From cdbfb21e489a168ea25a6737d7dbd9f481b868b9 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 24 Nov 2025 10:38:53 +0100 Subject: [PATCH 21/36] ref(rate-limit): handle profile and check_in rate limiting (#1970) --- src/Transport/HttpTransport.php | 14 ++++ src/Transport/RateLimiter.php | 16 +++- tests/Transport/HttpTransportTest.php | 104 +++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index f47867fe84..71da8d5a39 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -100,6 +100,20 @@ public function send(Event $event): Result 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] + ); + } + } + $request = new Request(); $request->setStringBody($this->payloadSerializer->serialize($event)); diff --git a/src/Transport/RateLimiter.php b/src/Transport/RateLimiter.php index dbb8deccfc..ab219577fc 100644 --- a/src/Transport/RateLimiter.php +++ b/src/Transport/RateLimiter.php @@ -11,6 +11,11 @@ final class RateLimiter { + /** + * @var string + */ + public const DATA_CATEGORY_PROFILE = 'profile'; + /** * @var string */ @@ -21,6 +26,11 @@ final class RateLimiter */ private const DATA_CATEGORY_LOG_ITEM = 'log_item'; + /** + * @var string + */ + private const DATA_CATEGORY_CHECK_IN = 'monitor'; + /** * The name of the header to look at to know the rate limits for the events * categories supported by the server. @@ -103,9 +113,7 @@ public function handleResponse(Response $response): bool */ public function isRateLimited($eventType): bool { - $disabledUntil = $this->getDisabledUntil($eventType); - - return $disabledUntil > time(); + return $this->getDisabledUntil($eventType) > time(); } /** @@ -119,6 +127,8 @@ public function getDisabledUntil($eventType): int $eventType = self::DATA_CATEGORY_ERROR; } elseif ($eventType === 'log') { $eventType = self::DATA_CATEGORY_LOG_ITEM; + } elseif ($eventType === 'check_in') { + $eventType = self::DATA_CATEGORY_CHECK_IN; } return max($this->rateLimits['all'] ?? 0, $this->rateLimits[$eventType] ?? 0); diff --git a/tests/Transport/HttpTransportTest.php b/tests/Transport/HttpTransportTest.php index 942fb9b494..66d564b85c 100644 --- a/tests/Transport/HttpTransportTest.php +++ b/tests/Transport/HttpTransportTest.php @@ -12,6 +12,7 @@ use Sentry\HttpClient\HttpClientInterface; use Sentry\HttpClient\Response; use Sentry\Options; +use Sentry\Profiling\Profile; use Sentry\Serializer\PayloadSerializerInterface; use Sentry\Transport\HttpTransport; use Sentry\Transport\ResultStatus; @@ -25,7 +26,7 @@ final class HttpTransportTest extends TestCase private $logger; /** - * @var MockObject&HttpAsyncClientInterface + * @var MockObject&HttpClientInterface */ private $httpClient; @@ -180,7 +181,7 @@ public function testSendFailsDueToHttpClientException(): void $this->assertSame(ResultStatus::failed(), $result->getStatus()); } - public function testSendFailsDueToCulrError(): void + public function testSendFailsDueToCurlError(): void { $event = Event::createEvent(); @@ -263,6 +264,105 @@ public function testSendFailsDueToExceedingRateLimits(): void $this->assertSame(ResultStatus::rateLimit(), $result->getStatus()); } + /** + * @group time-sensitive + */ + public function testDropsProfileAndSendsTransactionWhenProfileRateLimited(): void + { + ClockMock::withClockMock(1644105600); + + $transport = new HttpTransport( + new Options(['dsn' => 'http://public@example.com/1']), + $this->httpClient, + $this->payloadSerializer, + $this->logger + ); + + $event = Event::createTransaction(); + $event->setSdkMetadata('profile', new Profile()); + + $this->payloadSerializer->expects($this->exactly(2)) + ->method('serialize') + ->willReturn('{"foo":"bar"}'); + + $this->httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnOnConsecutiveCalls( + new Response(429, ['X-Sentry-Rate-Limits' => ['60:profile:key']], ''), + new Response(200, [], '') + ); + + // First request is rate limited because of profiles + $result = $transport->send($event); + + $this->assertEquals(ResultStatus::rateLimit(), $result->getStatus()); + + // profile information is still present + $this->assertNotNull($event->getSdkMetadata('profile')); + + $event = Event::createTransaction(); + $event->setSdkMetadata('profile', new Profile()); + + $this->logger->expects($this->once()) + ->method('warning') + ->with( + $this->stringContains('Rate limit exceeded for sending requests of type "profile".'), + ['event' => $event] + ); + + $result = $transport->send($event); + + // Sending transaction is successful because only profiles are rate limited + $this->assertEquals(ResultStatus::success(), $result->getStatus()); + + // profile information is removed because it was rate limited + $this->assertNull($event->getSdkMetadata('profile')); + } + + /** + * @group time-sensitive + */ + public function testCheckInsAreRateLimited(): void + { + ClockMock::withClockMock(1644105600); + + $transport = new HttpTransport( + new Options(['dsn' => 'http://public@example.com/1']), + $this->httpClient, + $this->payloadSerializer, + $this->logger + ); + + $event = Event::createCheckIn(); + + $this->payloadSerializer->expects($this->exactly(1)) + ->method('serialize') + ->willReturn('{"foo":"bar"}'); + + $this->httpClient->expects($this->exactly(1)) + ->method('sendRequest') + ->willReturn( + new Response(429, ['X-Sentry-Rate-Limits' => ['60:monitor:key']], '') + ); + + $result = $transport->send($event); + + $this->assertEquals(ResultStatus::rateLimit(), $result->getStatus()); + + $event = Event::createCheckIn(); + + $this->logger->expects($this->once()) + ->method('warning') + ->with( + $this->stringContains('Rate limit exceeded for sending requests of type "check_in".'), + ['event' => $event] + ); + + $result = $transport->send($event); + + $this->assertEquals(ResultStatus::rateLimit(), $result->getStatus()); + } + public function testClose(): void { $transport = new HttpTransport( From 856d46215ec0f9aac57d125eb5973a20b7eddb96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:33:19 +0100 Subject: [PATCH 22/36] chore(deps): bump actions/checkout from 5 to 6 (#1971) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/publish-release.yaml | 2 +- .github/workflows/static-analysis.yaml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4921fc52e..83775534fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 2 diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 8fee24055e..9ae5cc3c4f 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@v5 + - uses: actions/checkout@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 d9e875a520..ba6ffe12cf 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@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 From ccd7081fbe032e8c7e32cb4913ec182aff378a72 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:34:43 +0100 Subject: [PATCH 23/36] chore(deps): bump actions/create-github-app-token from 2.1.4 to 2.2.0 (#1972) 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 9ae5cc3c4f..705c0958a1 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@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From c6f5826ebfc3ae4cccad3e2d7ef4abe8098084aa Mon Sep 17 00:00:00 2001 From: Karsten Dambekalns Date: Thu, 27 Nov 2025 12:52:01 +0000 Subject: [PATCH 24/36] docs: Update list of integrations regarding Neos (& TYPO3) (#1975) --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b46be953d9..eb9e7ff346 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,13 @@ The following integrations are available and maintained by members of the Sentry - [WordPress](https://wordpress.org/plugins/wp-sentry-integration/) - Magento 2 by [JustBetter](https://github.com/justbetter/magento2-sentry) or by [Mygento](https://github.com/mygento/module-sentry) - [Joomla!](https://github.com/AlterBrains/sentry-joomla) +- Neos Flow (and CMS) using [flownative/sentry](https://github.com/flownative/flow-sentry) or [networkteam/sentryclient](https://github.com/networkteam/Networkteam.SentryClient) +- Neos CMS with specific Fusion handling using [networkteam/neos-sentryclient](https://github.com/networkteam/Netwokteam.Neos.SentryClient) +- [TYPO3](https://github.com/networkteam/sentry_client) - ... feel free to be famous, create a port to your favourite platform! ## 3rd party integrations using the old SDK 3.x -- [Neos Flow](https://github.com/flownative/flow-sentry) - [ZendFramework](https://github.com/facile-it/sentry-module) - [Yii2](https://github.com/notamedia/yii2-sentry) - [Silverstripe](https://github.com/phptek/silverstripe-sentry) @@ -77,14 +79,11 @@ The following integrations are available and maintained by members of the Sentry ## 3rd party integrations using the old SDK 2.x -- [Neos Flow](https://github.com/networkteam/Networkteam.SentryClient) - [OXID eShop](https://github.com/OXIDprojects/sentry) -- [TYPO3](https://github.com/networkteam/sentry_client) - [CakePHP](https://github.com/Connehito/cake-sentry/tree/3.x) ## 3rd party integrations using the old SDK 1.x -- [Neos CMS](https://github.com/networkteam/Netwokteam.Neos.SentryClient) - [OpenCart](https://github.com/BurdaPraha/oc_sentry) - [TYPO3](https://github.com/networkteam/sentry_client/tree/2.1.1) From dc31502a74366721f8a6263a3f17f06184336841 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 27 Nov 2025 15:23:54 +0100 Subject: [PATCH 25/36] feat(metrics): introduce metrics (#1968) Co-authored-by: Michael Hoffmann --- phpstan-baseline.neon | 10 - src/Event.php | 17 +- src/EventType.php | 16 +- src/Metrics/Metrics.php | 20 +- src/Metrics/MetricsAggregator.php | 140 +++++++++++++ src/Metrics/TraceMetrics.php | 100 +++++++++ src/Metrics/Types/CounterMetric.php | 64 ++++++ src/Metrics/Types/DistributionMetric.php | 64 ++++++ src/Metrics/Types/GaugeMetric.php | 64 ++++++ src/Metrics/Types/Metric.php | 111 ++++++++++ src/Options.php | 62 ++++++ src/Serializer/EnvelopItems/MetricsItem.php | 52 +++++ src/Serializer/PayloadSerializer.php | 9 +- src/Tracing/Span.php | 4 +- src/{Metrics/MetricsUnit.php => Unit.php} | 13 +- src/Util/RingBuffer.php | 215 ++++++++++++++++++++ src/functions.php | 8 +- tests/Metrics/MetricsTest.php | 12 +- tests/Metrics/TraceMetricsTest.php | 119 +++++++++++ tests/Serializer/PayloadSerializerTest.php | 70 +++++-- tests/StubTransport.php | 47 +++++ tests/Util/RingBufferTest.php | 146 +++++++++++++ 22 files changed, 1309 insertions(+), 54 deletions(-) create mode 100644 src/Metrics/MetricsAggregator.php create mode 100644 src/Metrics/TraceMetrics.php create mode 100644 src/Metrics/Types/CounterMetric.php create mode 100644 src/Metrics/Types/DistributionMetric.php create mode 100644 src/Metrics/Types/GaugeMetric.php create mode 100644 src/Metrics/Types/Metric.php create mode 100644 src/Serializer/EnvelopItems/MetricsItem.php rename src/{Metrics/MetricsUnit.php => Unit.php} (92%) create mode 100644 src/Util/RingBuffer.php create mode 100644 tests/Metrics/TraceMetricsTest.php create mode 100644 tests/StubTransport.php create mode 100644 tests/Util/RingBufferTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3ff509ab0e..e4ce16ef57 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -25,21 +25,11 @@ parameters: count: 1 path: src/Dsn.php - - - message: "#^Method Sentry\\\\Event\\:\\:getMetrics\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Event.php - - message: "#^Method Sentry\\\\Event\\:\\:getMetricsSummary\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: src/Event.php - - - message: "#^Method Sentry\\\\Event\\:\\:setMetrics\\(\\) has parameter \\$metrics with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Event.php - - message: "#^Method Sentry\\\\Event\\:\\:setMetricsSummary\\(\\) has parameter \\$metricsSummary with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/Event.php b/src/Event.php index 5244f945c8..e2d2a8c0fc 100644 --- a/src/Event.php +++ b/src/Event.php @@ -7,6 +7,7 @@ use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Logs\Log; +use Sentry\Metrics\Types\Metric; use Sentry\Profiling\Profile; use Sentry\Tracing\Span; @@ -71,6 +72,11 @@ final class Event */ private $logs = []; + /** + * @var Metric[] + */ + private $metrics = []; + /** * @var string|null The name of the server (e.g. the host name) */ @@ -241,9 +247,6 @@ public static function createLogs(?EventId $eventId = null): self return new self($eventId, EventType::logs()); } - /** - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. - */ public static function createMetrics(?EventId $eventId = null): self { return new self($eventId, EventType::metrics()); @@ -446,18 +449,20 @@ public function setLogs(array $logs): self } /** - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. + * @return Metric[] */ public function getMetrics(): array { - return []; + return $this->metrics; } /** - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. + * @param Metric[] $metrics */ public function setMetrics(array $metrics): self { + $this->metrics = $metrics; + return $this; } diff --git a/src/EventType.php b/src/EventType.php index 3c2d13fb3f..679f96633d 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -47,12 +47,9 @@ public static function logs(): self return self::getInstance('log'); } - /** - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. - */ public static function metrics(): self { - return self::getInstance('metrics'); + return self::getInstance('trace_metric'); } /** @@ -71,6 +68,17 @@ public static function cases(): array ]; } + public function requiresEventId(): bool + { + switch ($this) { + case self::metrics(): + case self::logs(): + return false; + default: + return true; + } + } + public function __toString(): string { return $this->value; diff --git a/src/Metrics/Metrics.php b/src/Metrics/Metrics.php index 6f0fa67646..936538e19e 100644 --- a/src/Metrics/Metrics.php +++ b/src/Metrics/Metrics.php @@ -6,9 +6,15 @@ use Sentry\EventId; use Sentry\Tracing\SpanContext; +use Sentry\Unit; use function Sentry\trace; +class_alias(Unit::class, '\Sentry\Metrics\MetricsUnit'); + +/** + * @deprecated use TraceMetrics instead + */ class Metrics { /** @@ -28,12 +34,12 @@ public static function getInstance(): self /** * @param array $tags * - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. + * @deprecated Use TraceMetrics::count() instead. To be removed in 5.x. */ public function increment( string $key, float $value, - ?MetricsUnit $unit = null, + ?Unit $unit = null, array $tags = [], ?int $timestamp = null, int $stackLevel = 0 @@ -43,12 +49,12 @@ public function increment( /** * @param array $tags * - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. + * @deprecated Use TraceMetrics::distribution() instead. Metrics API is a no-op and will be removed in 5.x. */ public function distribution( string $key, float $value, - ?MetricsUnit $unit = null, + ?Unit $unit = null, array $tags = [], ?int $timestamp = null, int $stackLevel = 0 @@ -58,12 +64,12 @@ public function distribution( /** * @param array $tags * - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. + * @deprecated Use TraceMetrics::gauge() instead. To be removed in 5.x. */ public function gauge( string $key, float $value, - ?MetricsUnit $unit = null, + ?Unit $unit = null, array $tags = [], ?int $timestamp = null, int $stackLevel = 0 @@ -79,7 +85,7 @@ public function gauge( public function set( string $key, $value, - ?MetricsUnit $unit = null, + ?Unit $unit = null, array $tags = [], ?int $timestamp = null, int $stackLevel = 0 diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php new file mode 100644 index 0000000000..fa0dfd5be4 --- /dev/null +++ b/src/Metrics/MetricsAggregator.php @@ -0,0 +1,140 @@ + + */ + 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, + ]; + + /** + * @param int|float $value + * @param array $attributes + */ + public function add( + string $type, + string $name, + $value, + array $attributes, + ?Unit $unit + ): void { + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + if ($client instanceof Client) { + $options = $client->getOptions(); + + 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 ($options->shouldSendDefaultPii()) { + $hub->configureScope(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) { + $defaultAttributes['sentry.release'] = $release; + } + + $attributes += $defaultAttributes; + } + + $spanId = null; + $traceId = null; + + $span = $hub->getSpan(); + if ($span !== null) { + $spanId = $span->getSpanId(); + $traceId = $span->getTraceId(); + } else { + $hub->configureScope(function (Scope $scope) use (&$traceId, &$spanId) { + $propagationContext = $scope->getPropagationContext(); + $traceId = $propagationContext->getTraceId(); + $spanId = $propagationContext->getSpanId(); + }); + } + + $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) { + $beforeSendMetric = $client->getOptions()->getBeforeSendMetricCallback(); + $metric = $beforeSendMetric($metric); + if ($metric === null) { + return; + } + } + + $this->metrics->push($metric); + } + + public function flush(): ?EventId + { + if ($this->metrics->isEmpty()) { + return null; + } + + $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 new file mode 100644 index 0000000000..a3ef4a0a0b --- /dev/null +++ b/src/Metrics/TraceMetrics.php @@ -0,0 +1,100 @@ +aggregator = new MetricsAggregator(); + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new TraceMetrics(); + } + + return self::$instance; + } + + /** + * @param int|float $value + * @param array $attributes + */ + public function count( + string $name, + $value, + array $attributes = [], + ?Unit $unit = null + ): void { + $this->aggregator->add( + CounterMetric::TYPE, + $name, + $value, + $attributes, + $unit + ); + } + + /** + * @param int|float $value + * @param array $attributes + */ + public function distribution( + string $name, + $value, + array $attributes = [], + ?Unit $unit = null + ): void { + $this->aggregator->add( + DistributionMetric::TYPE, + $name, + $value, + $attributes, + $unit + ); + } + + /** + * @param int|float $value + * @param array $attributes + */ + public function gauge( + string $name, + $value, + array $attributes = [], + ?Unit $unit = null + ): void { + $this->aggregator->add( + GaugeMetric::TYPE, + $name, + $value, + $attributes, + $unit + ); + } + + public function flush(): ?EventId + { + return $this->aggregator->flush(); + } +} diff --git a/src/Metrics/Types/CounterMetric.php b/src/Metrics/Types/CounterMetric.php new file mode 100644 index 0000000000..a309e176db --- /dev/null +++ b/src/Metrics/Types/CounterMetric.php @@ -0,0 +1,64 @@ + $attributes + */ + public function __construct( + string $name, + $value, + TraceId $traceId, + SpanId $spanId, + array $attributes, + float $timestamp, + ?Unit $unit + ) { + parent::__construct($name, $traceId, $spanId, $timestamp, $attributes, $unit); + + $this->value = (float) $value; + } + + /** + * @param int|float $value + */ + public function setValue($value): void + { + $this->value = $value; + } + + /** + * @return int|float + */ + public function getValue() + { + return $this->value; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/src/Metrics/Types/DistributionMetric.php b/src/Metrics/Types/DistributionMetric.php new file mode 100644 index 0000000000..bfbc85ce91 --- /dev/null +++ b/src/Metrics/Types/DistributionMetric.php @@ -0,0 +1,64 @@ + $attributes + */ + public function __construct( + string $name, + $value, + TraceId $traceId, + SpanId $spanId, + array $attributes, + float $timestamp, + ?Unit $unit + ) { + parent::__construct($name, $traceId, $spanId, $timestamp, $attributes, $unit); + + $this->value = (float) $value; + } + + /** + * @param int|float $value + */ + public function setValue($value): void + { + $this->value = $value; + } + + /** + * @return int|float + */ + public function getValue() + { + return $this->value; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/src/Metrics/Types/GaugeMetric.php b/src/Metrics/Types/GaugeMetric.php new file mode 100644 index 0000000000..2b58745be8 --- /dev/null +++ b/src/Metrics/Types/GaugeMetric.php @@ -0,0 +1,64 @@ + $attributes + */ + public function __construct( + string $name, + $value, + TraceId $traceId, + SpanId $spanId, + array $attributes, + float $timestamp, + ?Unit $unit + ) { + parent::__construct($name, $traceId, $spanId, $timestamp, $attributes, $unit); + + $this->value = (float) $value; + } + + /** + * @param int|float $value + */ + public function setValue($value): void + { + $this->value = $value; + } + + /** + * @return int|float + */ + public function getValue() + { + return $this->value; + } + + public function getType(): string + { + return self::TYPE; + } +} diff --git a/src/Metrics/Types/Metric.php b/src/Metrics/Types/Metric.php new file mode 100644 index 0000000000..999acbed9b --- /dev/null +++ b/src/Metrics/Types/Metric.php @@ -0,0 +1,111 @@ + $attributes + */ + public function __construct( + string $name, + TraceId $traceId, + SpanId $spanId, + float $timestamp, + array $attributes, + ?Unit $unit + ) { + $this->name = $name; + $this->unit = $unit; + $this->traceId = $traceId; + $this->spanId = $spanId; + $this->timestamp = $timestamp; + $this->attributes = new AttributeBag(); + + foreach ($attributes as $key => $value) { + $this->attributes->set($key, $value); + } + } + + /** + * @param int|float $value + */ + abstract public function setValue($value): void; + + abstract public function getType(): string; + + /** + * @return int|float + */ + abstract public function getValue(); + + public function getName(): string + { + return $this->name; + } + + public function getUnit(): ?Unit + { + return $this->unit; + } + + public function getTraceId(): TraceId + { + return $this->traceId; + } + + public function getSpanId(): SpanId + { + return $this->spanId; + } + + public function getAttributes(): AttributeBag + { + return $this->attributes; + } + + public function getTimestamp(): float + { + return $this->timestamp; + } +} diff --git a/src/Options.php b/src/Options.php index 84ead78367..8e1ee2279d 100644 --- a/src/Options.php +++ b/src/Options.php @@ -10,6 +10,7 @@ use Sentry\Integration\ErrorListenerIntegration; use Sentry\Integration\IntegrationInterface; use Sentry\Logs\Log; +use Sentry\Metrics\Types\Metric; use Sentry\Transport\TransportInterface; use Symfony\Component\OptionsResolver\Options as SymfonyOptions; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -177,6 +178,31 @@ public function getEnableLogs(): bool return $this->options['enable_logs'] ?? false; } + /** + * Sets if metrics should be enabled or not. + */ + public function setEnableMetrics(bool $enableTracing): self + { + $options = array_merge($this->options, ['enable_metrics' => $enableTracing]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + + /** + * Returns whether metrics are enabled or not. + */ + public function getEnableMetrics(): bool + { + /** + * @var bool $enableMetrics + */ + $enableMetrics = $this->options['enable_metrics'] ?? true; + + return $enableMetrics; + } + /** * Sets the sampling factor to apply to transactions. A value of 0 will deny * sending any transactions, and a value of 1 will send 100% of transactions. @@ -676,6 +702,35 @@ public function getBeforeSendMetricsCallback(): callable return $this->options['before_send_metrics']; } + /** + * Gets a callback that will be invoked before a metric is added. + * Returning `null` means that the metric will be discarded. + */ + public function getBeforeSendMetricCallback(): callable + { + /** + * @var callable $callback + */ + $callback = $this->options['before_send_metric']; + + return $callback; + } + + /** + * Sets a new callback that is invoked before metrics are sent. + * Returning `null` means that the metric will be discarded. + * + * @return $this + */ + public function setBeforeSendMetricCallback(callable $callback): self + { + $options = array_merge($this->options, ['before_send_metric' => $callback]); + + $this->options = $this->resolver->resolve($options); + + return $this; + } + /** * Sets a callable to be called to decide whether metrics should * be send or not. @@ -1220,6 +1275,7 @@ private function configureOptions(OptionsResolver $resolver): void 'sample_rate' => 1, 'enable_tracing' => null, 'enable_logs' => false, + 'enable_metrics' => true, 'traces_sample_rate' => null, 'traces_sampler' => null, 'profiles_sample_rate' => null, @@ -1256,10 +1312,14 @@ private function configureOptions(OptionsResolver $resolver): void }, /** * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. + * Use `before_send_metric` instead. */ 'before_send_metrics' => static function (Event $metrics): ?Event { return null; }, + 'before_send_metric' => static function (Metric $metric): Metric { + return $metric; + }, 'trace_propagation_targets' => null, 'strict_trace_propagation' => false, 'tags' => [], @@ -1290,6 +1350,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('enable_metrics', 'bool'); $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); @@ -1309,6 +1370,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('before_send', ['callable']); $resolver->setAllowedTypes('before_send_transaction', ['callable']); $resolver->setAllowedTypes('before_send_log', 'callable'); + $resolver->setAllowedTypes('before_send_metric', ['callable']); $resolver->setAllowedTypes('ignore_exceptions', 'string[]'); $resolver->setAllowedTypes('ignore_transactions', 'string[]'); $resolver->setAllowedTypes('trace_propagation_targets', ['null', 'string[]']); diff --git a/src/Serializer/EnvelopItems/MetricsItem.php b/src/Serializer/EnvelopItems/MetricsItem.php new file mode 100644 index 0000000000..1a2b410925 --- /dev/null +++ b/src/Serializer/EnvelopItems/MetricsItem.php @@ -0,0 +1,52 @@ +getMetrics(); + + $header = [ + 'type' => (string) EventType::metrics(), + 'item_count' => \count($metrics), + 'content_type' => 'application/vnd.sentry.items.trace-metric+json', + ]; + + return \sprintf( + "%s\n%s", + JSON::encode($header), + JSON::encode([ + 'items' => array_map(static function (Metric $metric): array { + return [ + 'timestamp' => $metric->getTimestamp(), + 'trace_id' => (string) $metric->getTraceId(), + 'span_id' => (string) $metric->getSpanId(), + 'name' => $metric->getName(), + 'value' => $metric->getValue(), + 'unit' => $metric->getUnit() ? (string) $metric->getUnit() : null, + 'type' => $metric->getType(), + 'attributes' => array_map(static function (Attribute $attribute): array { + return [ + 'type' => $attribute->getType(), + 'value' => $attribute->getValue(), + ]; + }, $metric->getAttributes()->all()), + ]; + }, $metrics), + ]) + ); + } +} diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index 4878cc767e..45de0f29f5 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -10,6 +10,7 @@ use Sentry\Serializer\EnvelopItems\CheckInItem; use Sentry\Serializer\EnvelopItems\EventItem; use Sentry\Serializer\EnvelopItems\LogsItem; +use Sentry\Serializer\EnvelopItems\MetricsItem; use Sentry\Serializer\EnvelopItems\ProfileItem; use Sentry\Serializer\EnvelopItems\TransactionItem; use Sentry\Tracing\DynamicSamplingContext; @@ -40,12 +41,15 @@ public function serialize(Event $event): string { // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers $envelopeHeader = [ - 'event_id' => (string) $event->getId(), '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(); + } + $dynamicSamplingContext = $event->getSdkMetadata('dynamic_sampling_context'); if ($dynamicSamplingContext instanceof DynamicSamplingContext) { $entries = $dynamicSamplingContext->getEntries(); @@ -73,6 +77,9 @@ public function serialize(Event $event): string case EventType::logs(): $items[] = LogsItem::toEnvelopeItem($event); break; + case EventType::metrics(): + $items[] = MetricsItem::toEnvelopeItem($event); + break; } return \sprintf("%s\n%s", JSON::encode($envelopeHeader), implode("\n", array_filter($items))); diff --git a/src/Tracing/Span.php b/src/Tracing/Span.php index e55c4c9487..09fe62a202 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -5,9 +5,9 @@ namespace Sentry\Tracing; use Sentry\EventId; -use Sentry\Metrics\MetricsUnit; use Sentry\SentrySdk; use Sentry\State\Scope; +use Sentry\Unit; /** * This class stores all the information about a span. @@ -548,7 +548,7 @@ public function setMetricsSummary( string $type, string $key, $value, - MetricsUnit $unit, + Unit $unit, array $tags ): void { } diff --git a/src/Metrics/MetricsUnit.php b/src/Unit.php similarity index 92% rename from src/Metrics/MetricsUnit.php rename to src/Unit.php index e94951fe3b..5a83ab720b 100644 --- a/src/Metrics/MetricsUnit.php +++ b/src/Unit.php @@ -2,12 +2,9 @@ declare(strict_types=1); -namespace Sentry\Metrics; +namespace Sentry; -/** - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. - */ -final class MetricsUnit implements \Stringable +final class Unit implements \Stringable { /** * @var string The value of the enum instance @@ -144,11 +141,17 @@ public static function percent(): self return self::getInstance('percent'); } + /** + * @deprecated `none` is not supported and will be removed in 5.x + */ public static function none(): self { return self::getInstance('none'); } + /** + * @deprecated custom unit types are currently not supported. Will be removed in 5.x + */ public static function custom(string $unit): self { return new self($unit); diff --git a/src/Util/RingBuffer.php b/src/Util/RingBuffer.php new file mode 100644 index 0000000000..7852f53dbd --- /dev/null +++ b/src/Util/RingBuffer.php @@ -0,0 +1,215 @@ + + */ + private $buffer; + + /** + * @var int + */ + private $capacity; + + /** + * Points at the first element in the buffer. + * + * @var int + */ + private $head = 0; + + /** + * Points at the index where the next insertion will happen. + * If the buffer is not full, this will point to an empty array index. + * When full, it will point to the position where the oldest element is. + * + * @var int + */ + private $tail = 0; + + /** + * @var int + */ + private $count = 0; + + /** + * Creates a new buffer with a fixed capacity. + */ + public function __construct(int $capacity) + { + if ($capacity <= 0) { + throw new \RuntimeException('RingBuffer capacity must be greater than 0'); + } + $this->capacity = $capacity; + $this->buffer = new \SplFixedArray($capacity); + } + + /** + * Returns how many elements can be stored in the buffer before it starts overwriting + * old elements. + */ + public function capacity(): int + { + return $this->capacity; + } + + /** + * The current number of stored elements. + */ + public function count(): int + { + return $this->count; + } + + /** + * Whether the buffer contains any element or not. + */ + public function isEmpty(): bool + { + return $this->count === 0; + } + + /** + * Whether the buffer is at capacity and will start to overwrite old elements on push. + */ + public function isFull(): bool + { + return $this->count === $this->capacity; + } + + /** + * Adds a new element to the back of the buffer. If the buffer is at capacity, it will + * overwrite the oldest element. + * + * Insertion order is still maintained. + * + * @param T $value + */ + public function push($value): void + { + $this->buffer[$this->tail] = $value; + + $this->tail = ($this->tail + 1) % $this->capacity; + + if ($this->isFull()) { + $this->head = ($this->head + 1) % $this->capacity; + } else { + ++$this->count; + } + } + + /** + * Returns and removes the first element in the buffer. + * If the buffer is empty, it will return null instead. + * + * @return T|null + */ + public function shift() + { + if ($this->isEmpty()) { + return null; + } + $value = $this->buffer[$this->head]; + + $this->buffer[$this->head] = null; + + $this->head = ($this->head + 1) % $this->capacity; + --$this->count; + + return $value; + } + + /** + * Returns the last element in the buffer without removing it. + * If the buffer is empty, it will return null instead. + * + * @return T|null + */ + public function peekBack() + { + if ($this->isEmpty()) { + return null; + } + $idx = ($this->tail - 1 + $this->capacity) % $this->capacity; + + return $this->buffer[$idx]; + } + + /** + * Returns the first element in the buffer without removing it. + * If the buffer is empty, it will return null instead. + * + * @return T|null + */ + public function peekFront() + { + if ($this->isEmpty()) { + return null; + } + + return $this->buffer[$this->head]; + } + + /** + * Resets the count and removes all elements from the buffer. + */ + public function clear(): void + { + for ($i = 0; $i < $this->count; ++$i) { + $this->buffer[($this->head + $i) % $this->capacity] = null; + } + $this->count = 0; + $this->head = 0; + $this->tail = 0; + } + + /** + * Returns the content of the buffer as array. The resulting array will have the size of `count` + * and not `capacity`. + * + * @return array + */ + public function toArray(): array + { + $result = []; + for ($i = 0; $i < $this->count; ++$i) { + $value = $this->buffer[($this->head + $i) % $this->capacity]; + /** @var T $value */ + $result[] = $value; + } + + return $result; + } + + /** + * Returns the content of the buffer and clears all elements that it contains in the process. + * + * @return array + */ + public function drain(): array + { + $result = $this->toArray(); + $this->clear(); + + return $result; + } +} diff --git a/src/functions.php b/src/functions.php index c013498218..af967c8f9d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -9,6 +9,7 @@ use Sentry\Integration\IntegrationInterface; use Sentry\Logs\Logs; use Sentry\Metrics\Metrics; +use Sentry\Metrics\TraceMetrics; use Sentry\State\Scope; use Sentry\Tracing\PropagationContext; use Sentry\Tracing\SpanContext; @@ -373,13 +374,18 @@ function logger(): Logs } /** - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. + * @deprecated use `trace_metrics` instead */ function metrics(): Metrics { return Metrics::getInstance(); } +function trace_metrics(): TraceMetrics +{ + return TraceMetrics::getInstance(); +} + /** * Adds a feature flag evaluation to the current scope. * When invoked repeatedly for the same name, the most recent value is used. diff --git a/tests/Metrics/MetricsTest.php b/tests/Metrics/MetricsTest.php index 1e33a53d55..3de480657e 100644 --- a/tests/Metrics/MetricsTest.php +++ b/tests/Metrics/MetricsTest.php @@ -104,12 +104,12 @@ public function testTiming(): void /** @var ClientInterface&MockObject $client */ $client = $this->createMock(ClientInterface::class); $client->expects($this->any()) - ->method('getOptions') - ->willReturn(new Options([ - 'release' => '1.0.0', - 'environment' => 'development', - 'attach_metric_code_locations' => true, - ])); + ->method('getOptions') + ->willReturn(new Options([ + 'release' => '1.0.0', + 'environment' => 'development', + 'attach_metric_code_locations' => true, + ])); $self = $this; diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php new file mode 100644 index 0000000000..39ebf07051 --- /dev/null +++ b/tests/Metrics/TraceMetricsTest.php @@ -0,0 +1,119 @@ +bindClient(new Client(new Options(), StubTransport::getInstance())); + StubTransport::$events = []; + } + + public function testCounterMetrics(): void + { + trace_metrics()->count('test-count', 2, ['foo' => 'bar']); + trace_metrics()->count('test-count', 2, ['foo' => 'bar']); + trace_metrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + $this->assertCount(2, $event->getMetrics()); + $metrics = $event->getMetrics(); + $metric = $metrics[0]; + $this->assertEquals('test-count', $metric->getName()); + $this->assertEquals(CounterMetric::TYPE, $metric->getType()); + $this->assertEquals(2, $metric->getValue()); + $this->assertArrayHasKey('foo', $metric->getAttributes()->toSimpleArray()); + } + + public function testGaugeMetrics(): void + { + trace_metrics()->gauge('test-gauge', 10, ['foo' => 'bar']); + trace_metrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + $this->assertCount(1, $event->getMetrics()); + $metrics = $event->getMetrics(); + $metric = $metrics[0]; + $this->assertEquals('test-gauge', $metric->getName()); + $this->assertEquals(GaugeMetric::TYPE, $metric->getType()); + $this->assertEquals(10, $metric->getValue()); + $this->assertArrayHasKey('foo', $metric->getAttributes()->toSimpleArray()); + } + + public function testDistributionMetrics(): void + { + trace_metrics()->distribution('test-distribution', 10, ['foo' => 'bar']); + trace_metrics()->flush(); + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + $this->assertCount(1, $event->getMetrics()); + $metrics = $event->getMetrics(); + $metric = $metrics[0]; + $this->assertEquals('test-distribution', $metric->getName()); + $this->assertEquals(DistributionMetric::TYPE, $metric->getType()); + $this->assertEquals(10, $metric->getValue()); + $this->assertArrayHasKey('foo', $metric->getAttributes()->toSimpleArray()); + } + + public function testMetricsBufferFull(): void + { + for ($i = 0; $i < MetricsAggregator::METRICS_BUFFER_SIZE + 100; ++$i) { + trace_metrics()->count('test', 1, ['foo' => 'bar']); + } + trace_metrics()->flush(); + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + $metrics = $event->getMetrics(); + $this->assertCount(MetricsAggregator::METRICS_BUFFER_SIZE, $metrics); + } + + public function testEnableMetrics(): void + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'enable_metrics' => false, + ]), StubTransport::getInstance())); + + trace_metrics()->count('test-count', 2, ['foo' => 'bar']); + trace_metrics()->flush(); + + $this->assertEmpty(StubTransport::$events); + } + + public function testBeforeSendMetricAltersContent() + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'before_send_metric' => static function (Metric $metric) { + $metric->setValue(99999); + + return $metric; + }, + ]), StubTransport::getInstance())); + + trace_metrics()->count('test-count', 2, ['foo' => 'bar']); + trace_metrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + + $this->assertCount(1, $event->getMetrics()); + $metric = $event->getMetrics()[0]; + $this->assertEquals(99999, $metric->getValue()); + } +} diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 1855bf487e..3d3cc4c75e 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -18,6 +18,9 @@ use Sentry\Frame; use Sentry\Logs\Log; use Sentry\Logs\LogLevel; +use Sentry\Metrics\Types\CounterMetric; +use Sentry\Metrics\Types\DistributionMetric; +use Sentry\Metrics\Types\GaugeMetric; use Sentry\MonitorConfig; use Sentry\MonitorSchedule; use Sentry\Options; @@ -31,6 +34,7 @@ use Sentry\Tracing\SpanStatus; use Sentry\Tracing\TraceId; use Sentry\Tracing\TransactionMetadata; +use Sentry\Unit; use Sentry\UserDataBag; use Sentry\Util\ClockMock; use Sentry\Util\SentryUid; @@ -65,7 +69,7 @@ public static function serializeAsEnvelopeDataProvider(): iterable yield [ Event::createEvent(new EventId('fc9442f5aef34234bb22b9a615e30ccd')), <<\/","server_name":"foo.example.com","release":"721e41770371db95eee98ca2707686226b993eda","environment":"production","fingerprint":["myrpc","POST","\/foo.bar"],"modules":{"my.module.name":"1.0"},"extra":{"my_key":1,"some_other_value":"foo bar"},"tags":{"ios_version":"4.0","context":"production"},"user":{"id":"unique_id","username":"my_user","email":"foo@example.com","ip_address":"127.0.0.1","segment":"my_segment"},"contexts":{"os":{"name":"Linux","version":"4.19.104-microsoft-standard","build":"#1 SMP Wed Feb 19 06:37:35 UTC 2020","kernel_version":"Linux 7944782cd697 4.19.104-microsoft-standard #1 SMP Wed Feb 19 06:37:35 UTC 2020 x86_64"},"runtime":{"name":"php","sapi":"cli","version":"7.4.3"},"electron":{"type":"runtime","name":"Electron","version":"4.0"}},"breadcrumbs":{"values":[{"type":"user","category":"log","level":"info","timestamp":1597790835},{"type":"navigation","category":"log","level":"info","timestamp":1597790835,"data":{"from":"\/login","to":"\/dashboard"}},{"type":"default","category":"log","level":"info","timestamp":1597790835,"data":{"0":"foo","1":"bar"}}]},"request":{"method":"POST","url":"http:\/\/absolute.uri\/foo","query_string":"query=foobar&page=2","data":{"foo":"bar"},"cookies":{"PHPSESSID":"298zf09hf012fh2"},"headers":{"content-type":"text\/html"},"env":{"REMOTE_ADDR":"127.0.0.1"}},"exception":{"values":[{"type":"Exception","value":"chained exception","stacktrace":{"frames":[{"filename":"file\/name.py","lineno":3,"in_app":true},{"filename":"file\/name.py","lineno":3,"in_app":false,"abs_path":"absolute\/file\/name.py","function":"myfunction","raw_function":"raw_function_name","pre_context":["def foo():"," my_var = 'foo'"],"context_line":" raise ValueError()","post_context":["","def main():"],"vars":{"my_var":"value"}}]},"mechanism":{"type":"generic","handled":true,"data":{"code":123}}},{"type":"Exception","value":"initial exception"}]}} TEXT @@ -183,7 +187,7 @@ public static function serializeAsEnvelopeDataProvider(): iterable yield [ $event, <<setMetrics([ + new CounterMetric('test-counter', 5, new TraceId('21160e9b836d479f81611368b2aa3d2c'), new SpanId('d051f34163cd45fb'), ['foo' => 'bar'], 1597790835.0, Unit::bit()), + ]); + + yield [ + $event, + <<setMetrics([ + new GaugeMetric('test-gauge', 5, new TraceId('21160e9b836d479f81611368b2aa3d2c'), new SpanId('d051f34163cd45fb'), ['foo' => 'bar'], ClockMock::microtime(true), Unit::second()), + ]); + + yield [ + $event, + <<setMetrics([ + new DistributionMetric('test-distribution', 5, new TraceId('21160e9b836d479f81611368b2aa3d2c'), new SpanId('d051f34163cd45fb'), ['foo' => 'bar'], ClockMock::microtime(true), Unit::day()), + ]); + + yield [ + $event, + <<push('foo'); + $buffer->push('bar'); + + $result = $buffer->toArray(); + $this->assertSame(2, $buffer->count()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testPeekBack(): void + { + $buffer = new RingBuffer(5); + $buffer->push('foo'); + $buffer->push('bar'); + + $this->assertSame(2, $buffer->count()); + $this->assertSame('bar', $buffer->peekBack()); + } + + public function testPeekBackEmpty(): void + { + $buffer = new RingBuffer(5); + + $this->assertEmpty($buffer); + $this->assertNull($buffer->peekBack()); + } + + public function testPeekFront(): void + { + $buffer = new RingBuffer(5); + $buffer->push('foo'); + $buffer->push('bar'); + + $this->assertSame(2, $buffer->count()); + $this->assertSame('foo', $buffer->peekFront()); + } + + public function testPeekFrontEmpty(): void + { + $buffer = new RingBuffer(5); + + $this->assertEmpty($buffer); + $this->assertNull($buffer->peekFront()); + } + + public function testFixedCapacity(): void + { + $buffer = new RingBuffer(2); + $buffer->push('foo'); + $buffer->push('bar'); + $buffer->push('baz'); + + $this->assertSame(2, $buffer->count()); + $this->assertEquals(['bar', 'baz'], $buffer->toArray()); + } + + public function testClear(): void + { + $buffer = new RingBuffer(5); + $buffer->push('foo'); + $buffer->push('bar'); + + $this->assertSame(2, $buffer->count()); + $buffer->clear(); + $this->assertTrue($buffer->isEmpty()); + } + + public function testDrain(): void + { + $buffer = new RingBuffer(5); + $buffer->push('foo'); + $buffer->push('bar'); + + $this->assertSame(2, $buffer->count()); + $result = $buffer->drain(); + $this->assertTrue($buffer->isEmpty()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testShift(): void + { + $buffer = new RingBuffer(5); + $buffer->push('foo'); + $buffer->push('bar'); + + $this->assertEquals('foo', $buffer->shift()); + $this->assertCount(1, $buffer); + } + + public function testShiftAndPush(): void + { + $buffer = new RingBuffer(5); + $buffer->push('foo'); + $buffer->push('bar'); + + $buffer->shift(); + + $buffer->push('baz'); + + $this->assertCount(2, $buffer); + $this->assertEquals(['bar', 'baz'], $buffer->toArray()); + } + + public function testCapacityOne(): void + { + $buffer = new RingBuffer(1); + $buffer->push('foo'); + $buffer->push('bar'); + + $this->assertCount(1, $buffer); + $this->assertSame('bar', $buffer->shift()); + } + + public function testInvalidCapacity(): void + { + $this->expectException(\RuntimeException::class); + $buffer = new RingBuffer(-1); + } + + public function testIsEmpty(): void + { + $buffer = new RingBuffer(5); + $this->assertTrue($buffer->isEmpty()); + } + + public function testIsFull(): void + { + $buffer = new RingBuffer(2); + $buffer->push('foo'); + $buffer->push('bar'); + $this->assertTrue($buffer->isFull()); + } +} From 43cc60dcaa19df1a288883e8e47c3baf4d242946 Mon Sep 17 00:00:00 2001 From: Alex Bouma Date: Thu, 27 Nov 2025 15:31:49 +0100 Subject: [PATCH 26/36] fix: spotlight not emitting logs or registering error handlers (#1964) --- src/Integration/IntegrationRegistry.php | 2 +- src/Transport/HttpTransport.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Integration/IntegrationRegistry.php b/src/Integration/IntegrationRegistry.php index be2df22eb0..e079266453 100644 --- a/src/Integration/IntegrationRegistry.php +++ b/src/Integration/IntegrationRegistry.php @@ -147,7 +147,7 @@ private function getDefaultIntegrations(Options $options): array new ModulesIntegration(), ]; - if ($options->getDsn() !== null) { + if ($options->getDsn() !== null || $options->isSpotlightEnabled()) { array_unshift($integrations, new ExceptionListenerIntegration(), new ErrorListenerIntegration(), new FatalErrorListenerIntegration()); } diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 71da8d5a39..12666ebd4b 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -171,6 +171,15 @@ private function sendRequestToSpotlight(Event $event): void return; } + $eventDescription = \sprintf( + '%s%s [%s]', + $event->getLevel() !== null ? $event->getLevel() . ' ' : '', + (string) $event->getType(), + (string) $event->getId() + ); + + $this->logger->info(\sprintf('Sending %s to Spotlight.', $eventDescription), ['event' => $event]); + $request = new Request(); $request->setStringBody($this->payloadSerializer->serialize($event)); From 0068471e246e02b7a6c4131c0ae593bada1e3c99 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 27 Nov 2025 15:51:49 +0100 Subject: [PATCH 27/36] Prepare 4.19.0 (#1976) --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be545ceea7..95ef15e411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # CHANGELOG +## 4.19.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.19.0. + +### Features + +- Add support for metrics. [(#1968)](https://github.com/getsentry/sentry-php/pull/1968) +```php +// Counter metric +\Sentry\trace_metrics()->count('test-counter', 10, ['my-attribute' => 'foo']); + +// Gauge metric +\Sentry\trace_metrics()->gauge('test-gauge', 50.0, ['my-attribute' => 'foo'], \Sentry\Unit::millisecond()); + +// Distribution metric +\Sentry\trace_metrics()->distribution('test-distribution', 20.0, ['my-attribute' => 'foo'], \Sentry\Unit::kilobyte()); + +// Flush metrics +\Sentry\trace_metrics()->flush(); +``` + +### Bug Fixes + +- Add rate limiting for profiles and cron check-ins. [(#1970)](https://github.com/getsentry/sentry-php/pull/1970) +- Fix Spotlight so it always registers the error integrations and emits transport logs even when no DSN is configured. [(#1964)](https://github.com/getsentry/sentry-php/pull/1964) + ## 4.18.1 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.18.1. From 1d29a07c8fb68ae9ad9bb8c3fecfaad3cbc23053 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 27 Nov 2025 14:53:55 +0000 Subject: [PATCH 28/36] release: 4.19.0 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 74c8cfa779..390ae5a3bc 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.18.1'; + public const SDK_VERSION = '4.19.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From f5d279069b47a3305dfe2a448522af7a763498db Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 2 Dec 2025 16:46:29 +0100 Subject: [PATCH 29/36] fix(metrics): don't cast all values to float, reject invalid types (#1981) --- .php-cs-fixer.dist.php | 3 ++ src/Metrics/MetricsAggregator.php | 8 +++++ src/Metrics/Types/CounterMetric.php | 2 +- src/Metrics/Types/DistributionMetric.php | 2 +- src/Metrics/Types/GaugeMetric.php | 2 +- src/Metrics/Types/Metric.php | 3 -- tests/Metrics/TraceMetricsTest.php | 39 ++++++++++++++++++++++++ 7 files changed, 53 insertions(+), 6 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 9cf23f9120..8a5c51fdb1 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,5 +1,7 @@ setRules([ '@PHP71Migration' => true, @@ -35,6 +37,7 @@ 'elements' => ['arrays'], ], 'no_whitespace_before_comma_in_array' => false, // Should be dropped when we drop support for PHP 7.x + 'stringable_for_to_string' => false, ]) ->setRiskyAllowed(true) ->setFinder( diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index fa0dfd5be4..e18ca7ddfa 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -56,6 +56,14 @@ public function add( $hub = SentrySdk::getCurrentHub(); $client = $hub->getClient(); + if (!\is_int($value) && !\is_float($value)) { + if ($client !== null) { + $client->getOptions()->getLoggerOrNullLogger()->debug('Metrics value is neither int nor float. Metric will be discarded'); + } + + return; + } + if ($client instanceof Client) { $options = $client->getOptions(); diff --git a/src/Metrics/Types/CounterMetric.php b/src/Metrics/Types/CounterMetric.php index a309e176db..ff7d636dc9 100644 --- a/src/Metrics/Types/CounterMetric.php +++ b/src/Metrics/Types/CounterMetric.php @@ -38,7 +38,7 @@ public function __construct( ) { parent::__construct($name, $traceId, $spanId, $timestamp, $attributes, $unit); - $this->value = (float) $value; + $this->value = $value; } /** diff --git a/src/Metrics/Types/DistributionMetric.php b/src/Metrics/Types/DistributionMetric.php index bfbc85ce91..6d2ea73069 100644 --- a/src/Metrics/Types/DistributionMetric.php +++ b/src/Metrics/Types/DistributionMetric.php @@ -38,7 +38,7 @@ public function __construct( ) { parent::__construct($name, $traceId, $spanId, $timestamp, $attributes, $unit); - $this->value = (float) $value; + $this->value = $value; } /** diff --git a/src/Metrics/Types/GaugeMetric.php b/src/Metrics/Types/GaugeMetric.php index 2b58745be8..22dc944c20 100644 --- a/src/Metrics/Types/GaugeMetric.php +++ b/src/Metrics/Types/GaugeMetric.php @@ -38,7 +38,7 @@ public function __construct( ) { parent::__construct($name, $traceId, $spanId, $timestamp, $attributes, $unit); - $this->value = (float) $value; + $this->value = $value; } /** diff --git a/src/Metrics/Types/Metric.php b/src/Metrics/Types/Metric.php index 999acbed9b..a66cbd6eac 100644 --- a/src/Metrics/Types/Metric.php +++ b/src/Metrics/Types/Metric.php @@ -9,9 +9,6 @@ use Sentry\Tracing\TraceId; use Sentry\Unit; -/** - * @internal - */ abstract class Metric { /** diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 39ebf07051..72f497002c 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -116,4 +116,43 @@ public function testBeforeSendMetricAltersContent() $metric = $event->getMetrics()[0]; $this->assertEquals(99999, $metric->getValue()); } + + public function testIntType() + { + trace_metrics()->count('test-count', 2, ['foo' => 'bar']); + trace_metrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + + $this->assertCount(1, $event->getMetrics()); + $metric = $event->getMetrics()[0]; + + $this->assertEquals('test-count', $metric->getName()); + $this->assertEquals(2, $metric->getValue()); + } + + public function testFloatType(): void + { + trace_metrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); + trace_metrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + + $this->assertCount(1, $event->getMetrics()); + $metric = $event->getMetrics()[0]; + + $this->assertEquals('test-gauge', $metric->getName()); + $this->assertEquals(10.50, $metric->getValue()); + } + + public function testInvalidTypeIsDiscarded(): void + { + // @phpstan-ignore-next-line + trace_metrics()->count('test-count', 'test-value'); + trace_metrics()->flush(); + + $this->assertEmpty(StubTransport::$events); + } } From 68c2faa1e3323a5bc4f382fccd179b68e06ca209 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 2 Dec 2025 16:53:18 +0100 Subject: [PATCH 30/36] Prepare 4.19.1 (#1982) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ef15e411..249cfb0938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 4.19.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.19.1. + +### Bug Fixes + +- Don't cast metrics value to `float` in constructor, drop invalid metrics instead. [(#1981)](https://github.com/getsentry/sentry-php/pull/1981) + ## 4.19.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.19.0. From 1c21d60bebe67c0122335bd3fe977990435af0a3 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 2 Dec 2025 15:57:41 +0000 Subject: [PATCH 31/36] release: 4.19.1 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 390ae5a3bc..e54cb3d29d 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.0'; + public const SDK_VERSION = '4.19.1'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). From 1fbcf15a35deadd6710ea0dae19f23aea61286c8 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 3 Dec 2025 13:34:12 +0100 Subject: [PATCH 32/36] ref(tests): move `ClockMock` into `tests` (#1983) --- psalm.xml.dist | 1 - tests/BreadcrumbTest.php | 2 +- tests/Metrics/MetricsTest.php | 2 +- tests/Serializer/PayloadSerializerTest.php | 2 +- {src/Util => tests/TestUtil}/ClockMock.php | 2 +- tests/Tracing/SpanTest.php | 2 +- tests/Tracing/TransactionTest.php | 3 +-- tests/Transport/HttpTransportTest.php | 2 +- tests/Transport/RateLimiterTest.php | 2 +- tests/bootstrap.php | 2 +- 10 files changed, 9 insertions(+), 11 deletions(-) rename {src/Util => tests/TestUtil}/ClockMock.php (99%) diff --git a/psalm.xml.dist b/psalm.xml.dist index 502f940b3b..ea60216a6e 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -10,7 +10,6 @@ - diff --git a/tests/BreadcrumbTest.php b/tests/BreadcrumbTest.php index 77329f191d..932b49a1c8 100644 --- a/tests/BreadcrumbTest.php +++ b/tests/BreadcrumbTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use Sentry\Breadcrumb; -use Sentry\Util\ClockMock; +use Sentry\Tests\TestUtil\ClockMock; final class BreadcrumbTest extends TestCase { diff --git a/tests/Metrics/MetricsTest.php b/tests/Metrics/MetricsTest.php index 3de480657e..760bf90c0f 100644 --- a/tests/Metrics/MetricsTest.php +++ b/tests/Metrics/MetricsTest.php @@ -11,9 +11,9 @@ use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; +use Sentry\Tests\TestUtil\ClockMock; use Sentry\Tracing\SpanContext; use Sentry\Tracing\TransactionContext; -use Sentry\Util\ClockMock; use function Sentry\metrics; diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 3d3cc4c75e..53f3235e84 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -28,6 +28,7 @@ use Sentry\Serializer\PayloadSerializer; use Sentry\Severity; use Sentry\Stacktrace; +use Sentry\Tests\TestUtil\ClockMock; use Sentry\Tracing\DynamicSamplingContext; use Sentry\Tracing\Span; use Sentry\Tracing\SpanId; @@ -36,7 +37,6 @@ use Sentry\Tracing\TransactionMetadata; use Sentry\Unit; use Sentry\UserDataBag; -use Sentry\Util\ClockMock; use Sentry\Util\SentryUid; /** diff --git a/src/Util/ClockMock.php b/tests/TestUtil/ClockMock.php similarity index 99% rename from src/Util/ClockMock.php rename to tests/TestUtil/ClockMock.php index 004291d39e..73809f591d 100644 --- a/src/Util/ClockMock.php +++ b/tests/TestUtil/ClockMock.php @@ -26,7 +26,7 @@ * THE SOFTWARE. */ -namespace Sentry\Util; +namespace Sentry\Tests\TestUtil; /** * @author Nicolas Grekas diff --git a/tests/Tracing/SpanTest.php b/tests/Tracing/SpanTest.php index e0db7c6d58..cf8956172d 100644 --- a/tests/Tracing/SpanTest.php +++ b/tests/Tracing/SpanTest.php @@ -5,13 +5,13 @@ namespace Sentry\Tests\Tracing; use PHPUnit\Framework\TestCase; +use Sentry\Tests\TestUtil\ClockMock; use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; -use Sentry\Util\ClockMock; /** * @group time-sensitive diff --git a/tests/Tracing/TransactionTest.php b/tests/Tracing/TransactionTest.php index 61c8d44f6a..76a2c3aab6 100644 --- a/tests/Tracing/TransactionTest.php +++ b/tests/Tracing/TransactionTest.php @@ -12,11 +12,10 @@ use Sentry\Options; use Sentry\State\Hub; use Sentry\State\HubInterface; -use Sentry\Tracing\Span; +use Sentry\Tests\TestUtil\ClockMock; use Sentry\Tracing\SpanContext; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; -use Sentry\Util\ClockMock; /** * @group time-sensitive diff --git a/tests/Transport/HttpTransportTest.php b/tests/Transport/HttpTransportTest.php index 66d564b85c..1d34137aed 100644 --- a/tests/Transport/HttpTransportTest.php +++ b/tests/Transport/HttpTransportTest.php @@ -14,9 +14,9 @@ use Sentry\Options; use Sentry\Profiling\Profile; use Sentry\Serializer\PayloadSerializerInterface; +use Sentry\Tests\TestUtil\ClockMock; use Sentry\Transport\HttpTransport; use Sentry\Transport\ResultStatus; -use Sentry\Util\ClockMock; final class HttpTransportTest extends TestCase { diff --git a/tests/Transport/RateLimiterTest.php b/tests/Transport/RateLimiterTest.php index c1112e9ab0..6a34556c7f 100644 --- a/tests/Transport/RateLimiterTest.php +++ b/tests/Transport/RateLimiterTest.php @@ -7,8 +7,8 @@ use PHPUnit\Framework\TestCase; use Sentry\EventType; use Sentry\HttpClient\Response; +use Sentry\Tests\TestUtil\ClockMock; use Sentry\Transport\RateLimiter; -use Sentry\Util\ClockMock; /** * @group time-sensitive diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 49e701ceb3..a3a4166793 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,9 +5,9 @@ use Sentry\Breadcrumb; use Sentry\Event; use Sentry\Metrics\Metrics; +use Sentry\Tests\TestUtil\ClockMock; use Sentry\Tracing\Span; use Sentry\Transport\RateLimiter; -use Sentry\Util\ClockMock; require_once __DIR__ . '/../vendor/autoload.php'; From a3ff5466b040d8f5c6da278d53cca4bbf4b253d7 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 4 Dec 2025 11:10:16 +0100 Subject: [PATCH 33/36] ref(spotlight): normalize spotlight URL to not end with `/stream` (#1984) --- src/Options.php | 15 ++++++++++++- tests/OptionsTest.php | 52 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/Options.php b/src/Options.php index 8e1ee2279d..39c3d92e4b 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1408,6 +1408,7 @@ private function configureOptions(OptionsResolver $resolver): void return array_map([$this, 'normalizeAbsolutePath'], $value); }); + $resolver->setNormalizer('spotlight_url', \Closure::fromCallable([$this, 'normalizeSpotlightUrl'])); $resolver->setNormalizer('spotlight', \Closure::fromCallable([$this, 'normalizeBooleanOrUrl'])); $resolver->setNormalizer('in_app_exclude', function (SymfonyOptions $options, array $value) { @@ -1445,12 +1446,24 @@ private function normalizeBooleanOrUrl(SymfonyOptions $options, ?string $boolean } if (filter_var($booleanOrUrl, \FILTER_VALIDATE_URL)) { - return $booleanOrUrl; + return $this->normalizeSpotlightUrl($options, $booleanOrUrl); } return filter_var($booleanOrUrl, \FILTER_VALIDATE_BOOLEAN); } + /** + * Normalizes the spotlight URL by removing the `/stream` at the end if present. + */ + private function normalizeSpotlightUrl(SymfonyOptions $options, string $url): string + { + if (substr_compare($url, '/stream', -7, 7) === 0) { + return substr($url, 0, -7); + } + + return $url; + } + /** * Normalizes the DSN option by parsing the host, public and secret keys and * an optional path. diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index fed07e9bba..d229dad8b2 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -751,4 +751,56 @@ public static function enableTracingDataProvider(): array [true, null, true], ]; } + + /** + * @dataProvider spotlightUrlNormalizationDataProvider + */ + public function testSpotlightUrlNormalization(array $data, string $expected): void + { + $options = new Options($data); + $this->assertSame($expected, $options->getSpotlightUrl()); + } + + public static function spotlightUrlNormalizationDataProvider(): \Generator + { + yield [['spotlight_url' => 'http://localhost:8969'], 'http://localhost:8969']; + yield [['spotlight_url' => 'http://localhost:8969/stream'], 'http://localhost:8969']; + yield [['spotlight_url' => 'http://localhost:8969/foo'], 'http://localhost:8969/foo']; + yield [['spotlight_url' => 'http://localhost:8969/foo/stream'], 'http://localhost:8969/foo']; + yield [['spotlight_url' => 'http://localhost:8969/stream/foo'], 'http://localhost:8969/stream/foo']; + yield [['spotlight' => 'http://localhost:8969'], 'http://localhost:8969']; + yield [['spotlight' => 'http://localhost:8969/stream'], 'http://localhost:8969']; + yield [['spotlight' => 'http://localhost:8969/foo'], 'http://localhost:8969/foo']; + yield [['spotlight' => 'http://localhost:8969/foo/stream'], 'http://localhost:8969/foo']; + yield [['spotlight' => 'http://localhost:8969/stream/foo'], 'http://localhost:8969/stream/foo']; + } + + /** + * @dataProvider setSpotlightUrlNormalizationDataProvider + */ + public function testSetSpotlightUrlNormalization(string $url, string $expected): void + { + $options = new Options(); + $options->setSpotlightUrl($url); + $this->assertSame($expected, $options->getSpotlightUrl()); + } + + /** + * @dataProvider setSpotlightUrlNormalizationDataProvider + */ + public function testEnableSpotlightNormalization(string $url, string $expected): void + { + $options = new Options(); + $options->enableSpotlight($url); + $this->assertSame($expected, $options->getSpotlightUrl()); + } + + public static function setSpotlightUrlNormalizationDataProvider(): \Generator + { + yield ['http://localhost:8969', 'http://localhost:8969']; + yield ['http://localhost:8969/stream', 'http://localhost:8969']; + yield ['http://localhost:8969/foo', 'http://localhost:8969/foo']; + yield ['http://localhost:8969/foo/stream', 'http://localhost:8969/foo']; + yield ['http://localhost:8969/stream/foo', 'http://localhost:8969/stream/foo']; + } } From b4ccc3a01d136b46f886fb131dba402ddd757cee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:51:51 +0100 Subject: [PATCH 34/36] chore(deps): bump actions/cache from 4 to 5 (#1987) 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 83775534fc..f1102d63da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-${{ matrix.php.version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }} From c921f00d65383a4a68e613c312207ff7665b9639 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 16 Dec 2025 14:18:45 +0100 Subject: [PATCH 35/36] resolve merge conflicts --- src/Metrics/Metrics.php | 129 --------- src/Options.php | 4 +- src/State/Scope.php | 2 +- tests/Metrics/MetricsTest.php | 306 --------------------- tests/OptionsTest.php | 15 - tests/Serializer/PayloadSerializerTest.php | 12 +- tests/bootstrap.php | 2 - 7 files changed, 9 insertions(+), 461 deletions(-) delete mode 100644 src/Metrics/Metrics.php delete mode 100644 tests/Metrics/MetricsTest.php diff --git a/src/Metrics/Metrics.php b/src/Metrics/Metrics.php deleted file mode 100644 index 936538e19e..0000000000 --- a/src/Metrics/Metrics.php +++ /dev/null @@ -1,129 +0,0 @@ - $tags - * - * @deprecated Use TraceMetrics::count() instead. To be removed in 5.x. - */ - public function increment( - string $key, - float $value, - ?Unit $unit = null, - array $tags = [], - ?int $timestamp = null, - int $stackLevel = 0 - ): void { - } - - /** - * @param array $tags - * - * @deprecated Use TraceMetrics::distribution() instead. Metrics API is a no-op and will be removed in 5.x. - */ - public function distribution( - string $key, - float $value, - ?Unit $unit = null, - array $tags = [], - ?int $timestamp = null, - int $stackLevel = 0 - ): void { - } - - /** - * @param array $tags - * - * @deprecated Use TraceMetrics::gauge() instead. To be removed in 5.x. - */ - public function gauge( - string $key, - float $value, - ?Unit $unit = null, - array $tags = [], - ?int $timestamp = null, - int $stackLevel = 0 - ): void { - } - - /** - * @param int|string $value - * @param array $tags - * - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. - */ - public function set( - string $key, - $value, - ?Unit $unit = null, - array $tags = [], - ?int $timestamp = null, - int $stackLevel = 0 - ): void { - } - - /** - * @template T - * - * @param callable(): T $callback - * @param array $tags - * - * @return T - * - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. - */ - public function timing( - string $key, - callable $callback, - array $tags = [], - int $stackLevel = 0 - ) { - return trace( - function () use ($callback) { - return $callback(); - }, - SpanContext::make() - ->setOp('metric.timing') - ->setOrigin('auto.measure.metrics.timing') - ->setDescription($key) - ); - } - - /** - * @deprecated Metrics are no longer supported. Metrics API is a no-op and will be removed in 5.x. - */ - public function flush(): ?EventId - { - return null; - } -} diff --git a/src/Options.php b/src/Options.php index 828817068a..4294efcbf6 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1128,7 +1128,7 @@ private function normalizeBooleanOrUrl($booleanOrUrl) } if (filter_var($booleanOrUrl, \FILTER_VALIDATE_URL)) { - return $this->normalizeSpotlightUrl($options, $booleanOrUrl); + return $this->normalizeSpotlightUrl((string) $booleanOrUrl); } return filter_var($booleanOrUrl, \FILTER_VALIDATE_BOOLEAN); @@ -1137,7 +1137,7 @@ private function normalizeBooleanOrUrl($booleanOrUrl) /** * Normalizes the spotlight URL by removing the `/stream` at the end if present. */ - private function normalizeSpotlightUrl(SymfonyOptions $options, string $url): string + private function normalizeSpotlightUrl(string $url): string { if (substr_compare($url, '/stream', -7, 7) === 0) { return substr($url, 0, -7); diff --git a/src/State/Scope.php b/src/State/Scope.php index b65ce48047..b313eb73ba 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -417,7 +417,7 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op 'flag' => key($flag), 'result' => current($flag), ]; - }, $this->flags), + }, array_values($this->flags)), ]); } diff --git a/tests/Metrics/MetricsTest.php b/tests/Metrics/MetricsTest.php deleted file mode 100644 index 760bf90c0f..0000000000 --- a/tests/Metrics/MetricsTest.php +++ /dev/null @@ -1,306 +0,0 @@ -createMock(ClientInterface::class); - $client->expects($this->any()) - ->method('getOptions') - ->willReturn(new Options([ - 'release' => '1.0.0', - 'environment' => 'development', - 'attach_metric_code_locations' => true, - ])); - - $self = $this; - - $client->expects($this->never()) - ->method('captureEvent'); - - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); - - metrics()->increment( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->increment( - 'foo', - 2, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->flush(); - } - - public function testDistribution(): void - { - ClockMock::withClockMock(1699412953); - - /** @var ClientInterface&MockObject $client */ - $client = $this->createMock(ClientInterface::class); - $client->expects($this->any()) - ->method('getOptions') - ->willReturn(new Options([ - 'release' => '1.0.0', - 'environment' => 'development', - 'attach_metric_code_locations' => true, - ])); - - $self = $this; - - $client->expects($this->never()) - ->method('captureEvent'); - - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); - - metrics()->distribution( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->distribution( - 'foo', - 2, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->flush(); - } - - public function testTiming(): void - { - ClockMock::withClockMock(1699412953); - - /** @var ClientInterface&MockObject $client */ - $client = $this->createMock(ClientInterface::class); - $client->expects($this->any()) - ->method('getOptions') - ->willReturn(new Options([ - 'release' => '1.0.0', - 'environment' => 'development', - 'attach_metric_code_locations' => true, - ])); - - $self = $this; - - $client->expects($this->never()) - ->method('captureEvent'); - - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); - - $firstTimingResult = metrics()->timing( - 'foo', - static function () { - // Move the clock forward 1 second - ClockMock::withClockMock(1699412954); - - return '1second'; - }, - ['foo' => 'bar'] - ); - - $this->assertEquals('1second', $firstTimingResult); - - ClockMock::withClockMock(1699412953); - - $secondTimingResult = metrics()->timing( - 'foo', - static function () { - // Move the clock forward 2 seconds - ClockMock::withClockMock(1699412955); - }, - ['foo' => 'bar'] - ); - - $this->assertNull($secondTimingResult); - - metrics()->flush(); - } - - public function testGauge(): void - { - ClockMock::withClockMock(1699412953); - - /** @var ClientInterface&MockObject $client */ - $client = $this->createMock(ClientInterface::class); - $client->expects($this->any()) - ->method('getOptions') - ->willReturn(new Options([ - 'release' => '1.0.0', - 'environment' => 'development', - 'attach_metric_code_locations' => true, - ])); - - $self = $this; - - $client->expects($this->never()) - ->method('captureEvent'); - - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); - - metrics()->gauge( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->gauge( - 'foo', - 2, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->flush(); - } - - public function testSet(): void - { - ClockMock::withClockMock(1699412953); - - /** @var ClientInterface&MockObject $client */ - $client = $this->createMock(ClientInterface::class); - $client->expects($this->any()) - ->method('getOptions') - ->willReturn(new Options([ - 'release' => '1.0.0', - 'environment' => 'development', - 'attach_metric_code_locations' => true, - ])); - - $self = $this; - - $client->expects($this->never()) - ->method('captureEvent'); - - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); - - metrics()->set( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->set( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->set( - 'foo', - 'foo', - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->flush(); - } - - public function testMetricsSummary(): void - { - ClockMock::withClockMock(1699412953); - - /** @var ClientInterface&MockObject $client */ - $client = $this->createMock(ClientInterface::class); - $client->expects($this->any()) - ->method('getOptions') - ->willReturn(new Options([ - 'enable_tracing' => true, - 'environment' => 'development', - 'release' => '1.0.0', - ])); - - $self = $this; - - $client->expects($this->once()) - ->method('captureEvent') - ->with($this->callback(static function (Event $event) use ($self): bool { - $self->assertSame( - [], - $event->getMetricsSummary() - ); - - $self->assertSame( - [], - $event->getSpans()[0]->getMetricsSummary() - ); - - return true; - })); - - $hub = new Hub($client); - SentrySdk::setCurrentHub($hub); - - $transactionContext = TransactionContext::make() - ->setName('GET /metrics') - ->setOp('http.server'); - $transaction = $hub->startTransaction($transactionContext); - $hub->setSpan($transaction); - - metrics()->increment( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - $spanContext = SpanContext::make() - ->setOp('function'); - $span = $transaction->startChild($spanContext); - $hub->setSpan($span); - - metrics()->increment( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - metrics()->increment( - 'foo', - 1, - MetricsUnit::second(), - ['foo' => 'bar'] - ); - - $span->finish(); - $transaction->finish(); - } -} diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 7254dca765..0db7abcd2a 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -685,11 +685,6 @@ public function testSpotlightUrlNormalization(array $data, string $expected): vo public static function spotlightUrlNormalizationDataProvider(): \Generator { - yield [['spotlight_url' => 'http://localhost:8969'], 'http://localhost:8969']; - yield [['spotlight_url' => 'http://localhost:8969/stream'], 'http://localhost:8969']; - yield [['spotlight_url' => 'http://localhost:8969/foo'], 'http://localhost:8969/foo']; - yield [['spotlight_url' => 'http://localhost:8969/foo/stream'], 'http://localhost:8969/foo']; - yield [['spotlight_url' => 'http://localhost:8969/stream/foo'], 'http://localhost:8969/stream/foo']; yield [['spotlight' => 'http://localhost:8969'], 'http://localhost:8969']; yield [['spotlight' => 'http://localhost:8969/stream'], 'http://localhost:8969']; yield [['spotlight' => 'http://localhost:8969/foo'], 'http://localhost:8969/foo']; @@ -697,16 +692,6 @@ public static function spotlightUrlNormalizationDataProvider(): \Generator yield [['spotlight' => 'http://localhost:8969/stream/foo'], 'http://localhost:8969/stream/foo']; } - /** - * @dataProvider setSpotlightUrlNormalizationDataProvider - */ - public function testSetSpotlightUrlNormalization(string $url, string $expected): void - { - $options = new Options(); - $options->setSpotlightUrl($url); - $this->assertSame($expected, $options->getSpotlightUrl()); - } - /** * @dataProvider setSpotlightUrlNormalizationDataProvider */ diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 8551e7a533..25e5f5791e 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -482,9 +482,9 @@ public static function serializeAsEnvelopeDataProvider(): iterable yield [ $event, << Date: Tue, 16 Dec 2025 15:48:50 +0100 Subject: [PATCH 36/36] CS --- phpstan-baseline.neon | 15 --------------- phpstan.neon | 2 +- psalm.xml.dist | 2 +- src/Tracing/Span.php | 1 - src/functions.php | 1 - 5 files changed, 2 insertions(+), 19 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c0566f02c9..84b38ef08a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -28,21 +28,6 @@ parameters: count: 1 path: src/Dsn.php - - - message: "#^Method Sentry\\\\Event\\:\\:getMetricsSummary\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Event.php - - - - message: "#^Method Sentry\\\\Event\\:\\:setMetricsSummary\\(\\) has parameter \\$metricsSummary with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Event.php - - - - message: "#^Property Sentry\\\\Integration\\\\RequestIntegration\\:\\:\\$options \\(array\\{pii_sanitize_headers\\: array\\\\}\\) does not accept array\\.$#" - count: 1 - path: src/Integration/RequestIntegration.php - - message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" count: 1 diff --git a/phpstan.neon b/phpstan.neon index 61e18b96c3..612a889376 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,7 +10,7 @@ parameters: excludePaths: - tests/resources - tests/Fixtures - - src/Util/ClockMock.php + - tests/TestUtil/ClockMock.php dynamicConstantNames: - Monolog\Logger::API bootstrapFiles: diff --git a/psalm.xml.dist b/psalm.xml.dist index 2ac6e7397e..0584b303fd 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -11,7 +11,7 @@ - + diff --git a/src/Tracing/Span.php b/src/Tracing/Span.php index 7d8d5e277d..b2aa1adea5 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -7,7 +7,6 @@ use Sentry\EventId; use Sentry\SentrySdk; use Sentry\State\Scope; -use Sentry\Unit; /** * This class stores all the information about a span. diff --git a/src/functions.php b/src/functions.php index 6cdfe03ee8..e0a39c6857 100644 --- a/src/functions.php +++ b/src/functions.php @@ -8,7 +8,6 @@ use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\IntegrationInterface; use Sentry\Logs\Logs; -use Sentry\Metrics\Metrics; use Sentry\Metrics\TraceMetrics; use Sentry\State\Scope; use Sentry\Tracing\PropagationContext;