diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dee415f4..f1102d63d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,13 +35,14 @@ 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 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 2 @@ -60,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') }} diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 8fee24055..705c0958a 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -24,12 +24,12 @@ 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 }} - - 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 d9e875a52..ba6ffe12c 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 diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 9cf23f912..8a5c51fdb 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/CHANGELOG.md b/CHANGELOG.md index 7710789d0..249cfb093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,89 @@ # 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### Bug Fixes + +- Empty strings will no longer display `` when serialized. [(#1940)](https://github.com/getsentry/sentry-php/pull/1940) + +### Misc + +- 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. diff --git a/README.md b/README.md index 0b8c54e21..eb9e7ff34 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) @@ -113,7 +112,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 diff --git a/phpstan.neon b/phpstan.neon index 61e18b96c..612a88937 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 2ac6e7397..0584b303f 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -11,7 +11,7 @@ - + diff --git a/src/Attributes/Attribute.php b/src/Attributes/Attribute.php index ce792220e..3a79c84c5 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/Client.php b/src/Client.php index 1c51e6de2..e54cb3d29 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.19.1'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). diff --git a/src/Event.php b/src/Event.php index d3d7165e7..a7563a54f 100644 --- a/src/Event.php +++ b/src/Event.php @@ -8,6 +8,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; use Sentry\Util\DebugType; @@ -73,6 +74,11 @@ final class Event */ private $logs = []; + /** + * @var Metric[] + */ + private $metrics = []; + /** * @var string|null The name of the server (e.g. the host name) */ @@ -248,6 +254,11 @@ public static function createLogs(?EventId $eventId = null): self return new self($eventId, EventType::logs()); } + public static function createMetrics(?EventId $eventId = null): self + { + return new self($eventId, EventType::metrics()); + } + /** * Gets the ID of this event. */ @@ -444,6 +455,24 @@ public function setLogs(array $logs): self return $this; } + /** + * @return Metric[] + */ + public function getMetrics(): array + { + return $this->metrics; + } + + /** + * @param Metric[] $metrics + */ + public function setMetrics(array $metrics): self + { + $this->metrics = $metrics; + + return $this; + } + /** * Gets the name of the server. */ diff --git a/src/EventType.php b/src/EventType.php index 954172176..ec7d6b0d2 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -47,6 +47,11 @@ public static function logs(): self return self::getInstance('log'); } + public static function metrics(): self + { + return self::getInstance('trace_metric'); + } + /** * List of all cases on the enum. * @@ -59,9 +64,21 @@ public static function cases(): array self::transaction(), self::checkIn(), self::logs(), + self::metrics(), ]; } + 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/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index aa5873df3..2a373d930 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/Integration/IntegrationRegistry.php b/src/Integration/IntegrationRegistry.php index 0d67f9384..0c7b51cdf 100644 --- a/src/Integration/IntegrationRegistry.php +++ b/src/Integration/IntegrationRegistry.php @@ -148,7 +148,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/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index d73f89d3e..a1922d346 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -104,8 +104,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( @@ -116,6 +114,8 @@ public function add( continue; } + $attribute = Attribute::tryFromValue($value); + if ($attribute === null) { if ($sdkLogger !== null) { $sdkLogger->info( diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php new file mode 100644 index 000000000..e18ca7ddf --- /dev/null +++ b/src/Metrics/MetricsAggregator.php @@ -0,0 +1,148 @@ + + */ + 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 (!\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(); + + 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 000000000..a3ef4a0a0 --- /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 000000000..ff7d636dc --- /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 = $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 000000000..6d2ea7306 --- /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 = $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 000000000..22dc944c2 --- /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 = $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 000000000..a66cbd6ea --- /dev/null +++ b/src/Metrics/Types/Metric.php @@ -0,0 +1,108 @@ + $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/Monolog/LogsHandler.php b/src/Monolog/LogsHandler.php index 6d6842edd..9ee342a4f 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']) + $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']); + } } diff --git a/src/Options.php b/src/Options.php index cfdc4e91f..4294efcbf 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; /** @@ -130,6 +131,27 @@ public function getEnableLogs(): bool return $this->options['enable_logs'] ?? false; } + /** + * Sets if metrics should be enabled or not. + */ + public function setEnableMetrics(bool $enableTracing): self + { + return $this->updateOptions(['enable_metrics' => $enableTracing]); + } + + /** + * 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. @@ -514,6 +536,35 @@ public function setBeforeSendLogCallback(callable $callback): self return $this->updateOptions(['before_send_log' => $callback]); } + /** + * 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; + } + /** * Gets an allow list of trace propagation targets. * @@ -851,7 +902,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) @@ -922,6 +973,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('prefixes', 'string[]'); $resolver->setAllowedTypes('sample_rate', ['int', 'float']); $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']); @@ -939,6 +991,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[]']); @@ -991,6 +1044,7 @@ private function configureOptions(OptionsResolver $resolver): void 'prefixes' => array_filter(explode(\PATH_SEPARATOR, get_include_path() ?: '')), 'sample_rate' => 1, 'enable_logs' => false, + 'enable_metrics' => true, 'traces_sample_rate' => null, 'traces_sampler' => null, 'profiles_sample_rate' => null, @@ -1017,6 +1071,9 @@ private function configureOptions(OptionsResolver $resolver): void 'before_send_log' => static function (Log $log): Log { return $log; }, + 'before_send_metric' => static function (Metric $metric): Metric { + return $metric; + }, 'trace_propagation_targets' => null, 'strict_trace_propagation' => false, 'tags' => [], @@ -1071,12 +1128,24 @@ private function normalizeBooleanOrUrl($booleanOrUrl) } if (filter_var($booleanOrUrl, \FILTER_VALIDATE_URL)) { - return $booleanOrUrl; + return $this->normalizeSpotlightUrl((string) $booleanOrUrl); } return filter_var($booleanOrUrl, \FILTER_VALIDATE_BOOLEAN); } + /** + * Normalizes the spotlight URL by removing the `/stream` at the end if present. + */ + private function normalizeSpotlightUrl(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/src/Serializer/AbstractSerializer.php b/src/Serializer/AbstractSerializer.php index 5f5b5e753..803d898b4 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/src/Serializer/EnvelopItems/MetricsItem.php b/src/Serializer/EnvelopItems/MetricsItem.php new file mode 100644 index 000000000..1a2b41092 --- /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 75144a137..8b89c5574 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -11,6 +11,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; @@ -41,12 +42,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(); @@ -80,6 +84,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/Spotlight/SpotlightClient.php b/src/Spotlight/SpotlightClient.php index f32af4cda..0b56517b3 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, [], ''); } diff --git a/src/State/Scope.php b/src/State/Scope.php index 1bfcc5176..b313eb73b 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -24,6 +24,13 @@ */ class Scope { + /** + * Maximum number of flags allowed. We only track the first flags set. + * + * @internal + */ + public const MAX_FLAGS = 100; + /** * @var PropagationContext */ @@ -49,6 +56,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 */ @@ -138,6 +150,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. * @@ -339,6 +380,7 @@ public function clear(): self $this->fingerprint = []; $this->breadcrumbs = []; $this->tags = []; + $this->flags = []; $this->extra = []; $this->contexts = []; $this->attachments = []; @@ -368,6 +410,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), + ]; + }, array_values($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 baf04c679..b2aa1adea 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -21,6 +21,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 */ @@ -61,6 +68,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 */ @@ -327,6 +339,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. */ @@ -368,7 +394,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/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index f47867fe8..12666ebd4 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)); @@ -157,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)); diff --git a/src/Transport/RateLimiter.php b/src/Transport/RateLimiter.php index dbb8deccf..ab219577f 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/src/Unit.php b/src/Unit.php new file mode 100644 index 000000000..5a83ab720 --- /dev/null +++ b/src/Unit.php @@ -0,0 +1,173 @@ + A list of cached enum instances + */ + private static $instances = []; + + private function __construct(string $value) + { + $this->value = $value; + } + + public static function nanosecond(): self + { + return self::getInstance('nanosecond'); + } + + public static function microsecond(): self + { + return self::getInstance('microsecond'); + } + + public static function millisecond(): self + { + return self::getInstance('millisecond'); + } + + public static function second(): self + { + return self::getInstance('second'); + } + + public static function minute(): self + { + return self::getInstance('minute'); + } + + public static function hour(): self + { + return self::getInstance('hour'); + } + + public static function day(): self + { + return self::getInstance('day'); + } + + public static function week(): self + { + return self::getInstance('week'); + } + + public static function bit(): self + { + return self::getInstance('bit'); + } + + public static function byte(): self + { + return self::getInstance('byte'); + } + + public static function kilobyte(): self + { + return self::getInstance('kilobyte'); + } + + public static function kibibyte(): self + { + return self::getInstance('kibibyte'); + } + + public static function megabyte(): self + { + return self::getInstance('megabyte'); + } + + public static function mebibyte(): self + { + return self::getInstance('mebibyte'); + } + + public static function gigabyte(): self + { + return self::getInstance('gigabyte'); + } + + public static function gibibyte(): self + { + return self::getInstance('gibibyte'); + } + + public static function terabyte(): self + { + return self::getInstance('terabyte'); + } + + public static function tebibyte(): self + { + return self::getInstance('tebibyte'); + } + + public static function petabyte(): self + { + return self::getInstance('petabyte'); + } + + public static function pebibyte(): self + { + return self::getInstance('pebibyte'); + } + + public static function exabyte(): self + { + return self::getInstance('exabyte'); + } + + public static function exbibyte(): self + { + return self::getInstance('exbibyte'); + } + + public static function ratio(): self + { + return self::getInstance('ratio'); + } + + 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); + } + + public function __toString(): string + { + return $this->value; + } + + private static function getInstance(string $value): self + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } +} diff --git a/src/Util/RingBuffer.php b/src/Util/RingBuffer.php new file mode 100644 index 000000000..7852f53db --- /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 2f6d7293d..e0a39c685 100644 --- a/src/functions.php +++ b/src/functions.php @@ -8,6 +8,7 @@ use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\IntegrationInterface; use Sentry\Logs\Logs; +use Sentry\Metrics\TraceMetrics; use Sentry\State\Scope; use Sentry\Tracing\PropagationContext; use Sentry\Tracing\SpanContext; @@ -67,7 +68,7 @@ function init(array $options = []): void { $client = ClientBuilder::create($options)->getClient(); - SentrySdk::init($client); + SentrySdk::init()->bindClient($client); } /** @@ -344,3 +345,19 @@ function logger(): Logs { return Logs::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. + */ +function addFeatureFlag(string $name, bool $result): void +{ + SentrySdk::getCurrentHub()->configureScope(function (Scope $scope) use ($name, $result) { + $scope->addFeatureFlag($name, $result); + }); +} diff --git a/tests/Attributes/AttributeTest.php b/tests/Attributes/AttributeTest.php index 030f628bd..1cde9a4bf 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/BreadcrumbTest.php b/tests/BreadcrumbTest.php index 77329f191..932b49a1c 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/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index c7a7ca5f1..1478853e8 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 1917bd48b..3aab97419 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], ], ]); diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php new file mode 100644 index 000000000..72f497002 --- /dev/null +++ b/tests/Metrics/TraceMetricsTest.php @@ -0,0 +1,158 @@ +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()); + } + + 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); + } +} diff --git a/tests/Monolog/LogsHandlerTest.php b/tests/Monolog/LogsHandlerTest.php index 3aafdf07c..a18739b6b 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() diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index ec40ffa76..0db7abcd2 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -673,4 +673,41 @@ public function testErrorTypesOptionIsNotDynamiclyReadFromErrorReportingLevelWhe $this->assertSame($errorTypesOptionValue, $options->getErrorTypes()); } + + /** + * @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' => '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 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']; + } } diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 2dc2e5213..25e5f5791 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -19,6 +19,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; @@ -26,14 +29,15 @@ 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; 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; /** @@ -66,7 +70,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"},"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, + <<setAttachments([ @@ -436,9 +482,9 @@ public static function serializeAsEnvelopeDataProvider(): iterable yield [ $event, <<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(); @@ -365,6 +448,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(); @@ -377,6 +461,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 @@ -404,6 +489,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)); @@ -418,6 +504,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/StubTransport.php b/tests/StubTransport.php new file mode 100644 index 000000000..8a427622d --- /dev/null +++ b/tests/StubTransport.php @@ -0,0 +1,47 @@ + diff --git a/tests/Tracing/SpanTest.php b/tests/Tracing/SpanTest.php index 987feffb5..cf8956172 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 @@ -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()); + } } diff --git a/tests/Tracing/TransactionTest.php b/tests/Tracing/TransactionTest.php index 61c8d44f6..76a2c3aab 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 942fb9b49..1d34137ae 100644 --- a/tests/Transport/HttpTransportTest.php +++ b/tests/Transport/HttpTransportTest.php @@ -12,10 +12,11 @@ use Sentry\HttpClient\HttpClientInterface; use Sentry\HttpClient\Response; 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 { @@ -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( diff --git a/tests/Transport/RateLimiterTest.php b/tests/Transport/RateLimiterTest.php index c1112e9ab..6a34556c7 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/Util/RingBufferTest.php b/tests/Util/RingBufferTest.php new file mode 100644 index 000000000..8f184e449 --- /dev/null +++ b/tests/Util/RingBufferTest.php @@ -0,0 +1,146 @@ +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()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 49e701ceb..c107338d6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -4,10 +4,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'; @@ -26,5 +25,4 @@ ClockMock::register(Breadcrumb::class); ClockMock::register(Span::class); ClockMock::register(RateLimiter::class); -ClockMock::register(Metrics::class); ClockMock::register(Sentry\Serializer\PayloadSerializer::class); 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 acef1edd9..321ed418a 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 000000000..92e030588 --- /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 e7b457d2b..2c4b9143b 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 ba82e9bca..2c8e40c4d 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 000000000..cd3986d9c --- /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 000000000..88fdd8f5c --- /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 000000000..7d74233ce --- /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