From a0ad30d05c27db6f5a1708900de41c6778425135 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Mar 2026 16:51:20 +0100 Subject: [PATCH 1/4] merge master --- .github/workflows/ci.yml | 143 +++++++++ .github/workflows/publish-release.yaml | 2 +- AGENTS.md | 106 +++++++ CHANGELOG.md | 73 +++++ CLAUDE.md | 1 + composer.json | 3 + src/Client.php | 7 +- src/ClientReport/ClientReportAggregator.php | 84 ++++++ src/ClientReport/DiscardedEvent.php | 45 +++ src/ClientReport/Reason.php | 102 +++++++ src/Event.php | 29 ++ src/EventType.php | 15 + src/HttpClient/HttpClient.php | 60 ++++ src/Logs/Logs.php | 23 +- src/Logs/LogsAggregator.php | 4 +- src/Metrics/MetricsAggregator.php | 5 +- src/Metrics/TraceMetrics.php | 20 +- src/Monolog/BreadcrumbHandler.php | 7 +- src/Options.php | 48 ++- src/SentrySdk.php | 99 +++++- .../EnvelopItems/ClientReportItem.php | 31 ++ src/Serializer/PayloadSerializer.php | 39 ++- src/State/RuntimeContext.php | 72 +++++ src/State/RuntimeContextManager.php | 281 ++++++++++++++++++ src/Tracing/DynamicSamplingContext.php | 36 +-- src/Tracing/Traits/TraceHeaderParserTrait.php | 73 ++++- src/Transport/DataCategory.php | 83 ++++++ src/Transport/HttpTransport.php | 36 +-- src/Transport/ResultStatus.php | 11 + src/UserDataBag.php | 19 +- src/functions.php | 62 +++- .../ClientReportAggregatorTest.php | 118 ++++++++ tests/Fixtures/runtime/frankenphp/index.php | 84 ++++++ tests/Fixtures/runtime/roadrunner-worker.php | 138 +++++++++ tests/Fixtures/runtime/roadrunner.rr.yaml | 12 + tests/FunctionsTest.php | 171 +++++++++++ tests/HttpClient/HttpClientTest.php | 87 ++++++ .../FrankenPhpWorkerModeIntegrationTest.php | 35 +++ .../RoadRunnerWorkerModeIntegrationTest.php | 49 +++ ...meContextWorkerModeIntegrationTestCase.php | 256 ++++++++++++++++ tests/Metrics/TraceMetricsTest.php | 40 +-- tests/Monolog/BreadcrumbHandlerTest.php | 38 ++- tests/Monolog/RecordFactory.php | 10 +- tests/OptionsTest.php | 14 + tests/SentrySdkExtension.php | 9 + tests/SentrySdkTest.php | 239 +++++++++++++++ tests/Serializer/PayloadSerializerTest.php | 45 +++ tests/State/HubTest.php | 16 + tests/StubLogger.php | 38 +++ tests/StubTransport.php | 2 +- tests/Tracing/DynamicSamplingContextTest.php | 58 ++++ tests/Tracing/StrictTraceContinuationTest.php | 214 +++++++++++++ tests/Tracing/TransactionContextTest.php | 7 + tests/Transport/HttpTransportTest.php | 12 + tests/UserDataBagTest.php | 39 ++- ...ry_fatal_error_increases_memory_limit.phpt | 2 + ...ry_fatal_error_increases_memory_limit.phpt | 2 + ...errors_not_silencable_on_php_8_and_up.phpt | 2 +- ...spects_capture_silenced_errors_option.phpt | 2 + ..._option_regardless_of_error_reporting.phpt | 2 +- ...tegration_respects_error_types_option.phpt | 2 + .../error_handler_captures_fatal_error.phpt | 2 + ...rror_integration_captures_fatal_error.phpt | 2 + ...tegration_respects_error_types_option.phpt | 2 + .../error_handler_captures_fatal_error.phpt | 2 + ...rror_integration_captures_fatal_error.phpt | 2 + ...tegration_respects_error_types_option.phpt | 2 + tests/phpt/serialize_broken_class.phpt | 2 + ..._callable_that_makes_autoloader_throw.phpt | 2 + tests/phpt/test_callable_serialization.phpt | 2 + 70 files changed, 3220 insertions(+), 160 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 src/ClientReport/ClientReportAggregator.php create mode 100644 src/ClientReport/DiscardedEvent.php create mode 100644 src/ClientReport/Reason.php create mode 100644 src/Serializer/EnvelopItems/ClientReportItem.php create mode 100644 src/State/RuntimeContext.php create mode 100644 src/State/RuntimeContextManager.php create mode 100644 src/Transport/DataCategory.php create mode 100644 tests/ClientReport/ClientReportAggregatorTest.php create mode 100644 tests/Fixtures/runtime/frankenphp/index.php create mode 100644 tests/Fixtures/runtime/roadrunner-worker.php create mode 100644 tests/Fixtures/runtime/roadrunner.rr.yaml create mode 100644 tests/Integration/FrankenPhpWorkerModeIntegrationTest.php create mode 100644 tests/Integration/RoadRunnerWorkerModeIntegrationTest.php create mode 100644 tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php create mode 100644 tests/StubLogger.php create mode 100644 tests/Tracing/StrictTraceContinuationTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb51e01f22..2939342fb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +env: + FRANKENPHP_VERSION: v1.11.2 + ROADRUNNER_VERSION: v2025.1.7 + # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value concurrency: @@ -71,6 +75,10 @@ jobs: - name: Remove unused dependencies run: composer remove vimeo/psalm phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update + - name: Remove RoadRunner dependencies on unsupported PHP versions + if: ${{ matrix.os == 'windows-latest' || matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} + run: composer remove spiral/roadrunner-http spiral/roadrunner-worker --dev --no-interaction --no-update + - name: Set phpunit/phpunit version constraint run: composer require phpunit/phpunit:'${{ matrix.php.phpunit }}' --dev --no-interaction --no-update @@ -96,3 +104,138 @@ jobs: - name: Check benchmarks run: vendor/bin/phpbench run --revs=1 --iterations=1 if: ${{ matrix.dependencies == 'highest' && matrix.php.version == '8.4' }} + + runtime-tests-frankenphp: + name: Runtime tests (FrankenPHP) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Determine Composer cache directory + id: composer-cache + run: echo "directory=$(composer config cache-dir)" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ runner.os }}-runtime-frankenphp-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-runtime-frankenphp-composer- + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Install FrankenPHP + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + case "$(uname -m)" in + x86_64) asset="frankenphp-linux-x86_64" ;; + aarch64|arm64) asset="frankenphp-linux-aarch64" ;; + *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; + esac + + digest="$(gh api "repos/php/frankenphp/releases/tags/${FRANKENPHP_VERSION}" --jq ".assets[] | select(.name == \"${asset}\") | .digest")" + + if [ -z "${digest}" ]; then + echo "Unable to resolve digest for ${asset} (${FRANKENPHP_VERSION})." + exit 1 + fi + + gh release download "${FRANKENPHP_VERSION}" \ + --repo php/frankenphp \ + --pattern "${asset}" \ + --output "${asset}" + + echo "${digest#sha256:} ${asset}" | sha256sum --check -- + mkdir -p "${RUNNER_TEMP}/bin" + install -m 0755 "${asset}" "${RUNNER_TEMP}/bin/frankenphp" + echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" + "${RUNNER_TEMP}/bin/frankenphp" version + shell: bash + + - name: Run PHPUnit tests (excluding PHPT) + run: vendor/bin/phpunit tests --test-suffix Test.php --verbose + + runtime-tests-roadrunner: + name: Runtime tests (RoadRunner) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + + - name: Determine Composer cache directory + id: composer-cache + run: echo "directory=$(composer config cache-dir)" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ runner.os }}-runtime-roadrunner-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-runtime-roadrunner-composer- + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist + + - name: Install RoadRunner + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + case "$(uname -m)" in + x86_64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; + esac + + version_no_prefix="${ROADRUNNER_VERSION#v}" + asset="roadrunner-${version_no_prefix}-linux-${arch}.tar.gz" + + digest="$(gh api "repos/roadrunner-server/roadrunner/releases/tags/${ROADRUNNER_VERSION}" --jq ".assets[] | select(.name == \"${asset}\") | .digest")" + + if [ -z "${digest}" ]; then + echo "Unable to resolve digest for ${asset} (${ROADRUNNER_VERSION})." + exit 1 + fi + + gh release download "${ROADRUNNER_VERSION}" \ + --repo roadrunner-server/roadrunner \ + --pattern "${asset}" \ + --output "${asset}" + + echo "${digest#sha256:} ${asset}" | sha256sum --check -- + tar -xzf "${asset}" --strip-components=1 "${asset%.tar.gz}/rr" + mkdir -p "${RUNNER_TEMP}/bin" + install -m 0755 rr "${RUNNER_TEMP}/bin/rr" + echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" + "${RUNNER_TEMP}/bin/rr" --version + shell: bash + + - name: Run PHPUnit tests (excluding PHPT) + run: vendor/bin/phpunit tests --test-suffix Test.php --verbose + diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index bff6fefe1e..c97ec88abf 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@c6e2f04939b6ee67030588afbb5af76b127d8203 + uses: getsentry/craft@d630201930c7fe5aee6366ebee19ebb681128512 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..8c58acfc45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# AGENTS.md + +## Overview + +- `sentry/sentry` is the core PHP SDK, not an application or framework bundle. +- Public entry points include the autoloaded global helpers in + `src/functions.php` and the public classes and interfaces under `src/`. +- Use this file for repo-specific constraints that are easy to miss, and + explore the codebase for current implementation details. + +## Compatibility Rules + +- The minimum supported PHP version for shipped code is `7.2`. + `composer.json` requires `^7.2|^8.0`, so shipped code must remain valid on + PHP `7.2` unless support policy is intentionally being changed. +- This SDK has a broad public API surface. Treat changes to global helpers, + `Options`, `ClientBuilder`, `ClientInterface`, `HubInterface`, + `TransportInterface`, `IntegrationInterface`, tracing/logs/metrics types, and + Monolog handlers as BC-sensitive. +- Preserve the existing cross-version compatibility style. This repo supports + multiple `psr/log`, `symfony/options-resolver`, `guzzlehttp/psr7`, and + `monolog/monolog` major versions. +- Do not assume optional packages or binaries are available. Monolog is only a + suggested dependency, and FrankenPHP/RoadRunner worker coverage depends on + optional binaries and dev dependencies. +- `Spotlight` is treated as an active send path alongside DSN-based delivery. + Do not gate runtime setup or transport behavior on DSN alone. + +## Editing Guidance + +- Keep `declare(strict_types=1);` in PHP files. +- Follow the existing formatting rules from `.php-cs-fixer.dist.php`. +- If you add or change an SDK option, update the resolver/defaults in + `Options`, the relevant getters and setters, the `init()` option array-shape + docs in `src/functions.php`, and the affected tests. +- `src/functions.php` is autoloaded and part of the public API. Keep helper + signatures, phpdoc, and runtime behavior synchronized with the underlying + client, hub, and runtime-context implementation. Functions in + `src/functions.php` should use camelCase naming. +- `IntegrationRegistry` intentionally calls `setupOnce()` only once per + integration class during the process lifetime. Preserve de-duplication and + default-integration gating when changing integration setup behavior. +- `ErrorHandler` has fragile register-once, previous-handler chaining, reserved + memory, and out-of-memory behavior. Preserve that lifecycle carefully and add + PHPT coverage when changing fatal or silenced error handling. +- `SentrySdk::startContext()`, `endContext()`, and `withContext()` must keep + runtime-context isolation and best-effort flushing intact for logs, metrics, + and transport in long-running worker scenarios. +- `HttpTransport` and `PayloadSerializer` are tightly coupled. Preserve the + envelope item selection, Spotlight delivery path, dynamic sampling context, + and the transaction/profile relationship when changing transport or + serialization behavior. +- Monolog support spans multiple Monolog major versions through the + compatibility traits and handlers under `src/Monolog/`. Preserve that + compatibility style when changing logging integrations. +- `Client::SDK_VERSION` is updated by the release action via + `scripts/bump-version.sh`. Do not modify it manually as part of normal + development changes. + +## Test Expectations + +- Add tests with every behavior change. This is a library repo with broad + compatibility and regression coverage. +- New tests belong under `tests/`. +- `phpunit.xml.dist` defines a `unit` suite that includes both PHPUnit tests + and `tests/phpt`, plus a separate `oom` suite for `tests/phpt-oom`. +- Prefer targeted PHPUnit runs while iterating. +- After editing files, run the relevant formatting, lint, and test commands for + the code you changed. +- Before handing back substantive code changes, run `composer check` when + feasible and call out anything you could not run. +- If you change error handling, fatal error capture, or PHP-version-specific + behavior, add or update PHPT coverage. +- If you change runtime-context or worker-mode behavior, add or update focused + coverage for the FrankenPHP or RoadRunner paths as appropriate. + +## Tools And Commands + +- `phpstan.neon` only analyzes `src`, uses `phpstan-baseline.neon`, and will + not catch behavior regressions in `tests/` or PHPT coverage. +- `phpunit.xml.dist` is strict about unexpected output, so noisy debug output + will fail tests. +- This repo is a library, so do not expect a runnable application entrypoint. + +## Docs And Release Notes + +- `README.md` and `CHANGELOG.md` are updated manually during releases, so do + not modify them as part of normal development changes. +- If a change may require updates in the separate documentation repo, ask the + user whether to review `../sentry-docs` if that sibling checkout exists. If + it does not exist, ask the user for the local docs path first. If they opt + in, update that repo's `master` branch when safe, use git worktrees to + inspect the relevant docs, and suggest any needed changes to avoid stale + documentation. +- If a change affects installation, configuration, error handling, tracing, + profiling, metrics, logs, or worker-mode behavior, call out the likely + README or release-note follow-up in your summary instead of editing those + files automatically. + +## CI Notes + +- `.github/workflows/ci.yml` runs the PHPUnit compatibility matrix across + Ubuntu and Windows, lowest and highest dependencies, and separate runtime + jobs for FrankenPHP and RoadRunner. +- `.github/workflows/static-analysis.yaml` runs PHP-CS-Fixer, PHPStan, and + Psalm on single recent PHP versions rather than across the full test matrix. diff --git a/CHANGELOG.md b/CHANGELOG.md index 249cfb0938..ea268f4e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,78 @@ # CHANGELOG +## 4.22.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.22.0. + +### Features + +- Add support for the client report protocol without collecting client reports yet. [(#1978)](https://github.com/getsentry/sentry-php/pull/1978) +- Add `strict_trace_continuation` support to only continue incoming traces when the upstream baggage `org_id` matches the SDK org ID. [(#2016)](https://github.com/getsentry/sentry-php/pull/2016) + +Example: +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'strict_trace_continuation' => true, +]); +``` + +### Bug Fixes + +- Preserve sub-second timestamps for Monolog breadcrumbs. [(#2018)](https://github.com/getsentry/sentry-php/pull/2018) + +## 4.21.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.21.0. + +### Features + +- Add `RuntimeContext` and context lifecycle helpers for long-lived runtimes such as FrankenPHP and RoadRunner. [(#2011)](https://github.com/getsentry/sentry-php/pull/2011) + +Long-lived worker runtimes keep process memory between requests, which can cause scope data to leak from one request to the next. +`RuntimeContext` isolates SDK state per request and flushes buffered telemetry when the request context ends. +Data configured before a runtime context is started is copied into each new context as baseline scope data. + +Example: + +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', +]); + +$handler = static function (): void { + \Sentry\withContext(static function (): void { + // Handle one request. + }); +}; + +while (frankenphp_handle_request($handler)) {} +``` + +When using a runtime context, manual `\Sentry\flush()` calls are not needed for request teardown. +It is still necessary to finish transactions explicitly. + +## 4.20.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.20.0. + +### Features + +- Add a high-level `flush()` helper that flushes all buffered telemetry resources (logs and trace metrics). [(#1989)](https://github.com/getsentry/sentry-php/pull/1989) +- Add share handles to cURL (persistent for PHP >= 8.5, non-persistent otherwise). [(#1996)](https://github.com/getsentry/sentry-php/pull/1996) +- Handle HTTP 413 responses explicitly with a dedicated `content_too_large` status. [(#2008)](https://github.com/getsentry/sentry-php/pull/2008) + +### Bug Fixes + +- Normalize Spotlight URLs to optionally allow trailing `/stream`. [(#1984)](https://github.com/getsentry/sentry-php/pull/1984) +- Monolog messages are now filtered by their original Monolog level before being mapped to Sentry log levels. [(#1992)](https://github.com/getsentry/sentry-php/pull/1992) +- Handle bracketed IPv6 addresses (such as `[::1]`). [(#2007)](https://github.com/getsentry/sentry-php/pull/2007) +- Ignore propagated baggage `sample_rate` when no `sentry-trace` header is present. [(#2002)](https://github.com/getsentry/sentry-php/pull/2002) + +### Misc + +- Add the `traceMetrics()` helper and deprecate `trace_metrics()`. [(#1995)](https://github.com/getsentry/sentry-php/pull/1995) + ## 4.19.1 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.19.1. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/composer.json b/composer.json index cbc3ba203f..ad8c47f3fc 100644 --- a/composer.json +++ b/composer.json @@ -34,9 +34,12 @@ "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", "phpbench/phpbench": "^1.0", "phpstan/phpstan": "^1.3", "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6", "vimeo/psalm": "^4.17" }, "suggest": { diff --git a/src/Client.php b/src/Client.php index e54cb3d29d..23f546024a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.19.1'; + public const SDK_VERSION = '4.22.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). @@ -178,7 +178,10 @@ public function captureException(\Throwable $exception, ?Scope $scope = null, ?E */ public function captureEvent(Event $event, ?EventHint $hint = null, ?Scope $scope = null): ?EventId { - $event = $this->prepareEvent($event, $hint, $scope); + // Client reports don't need to be augmented in the prepareEvent pipeline. + if ($event->getType() !== EventType::clientReport()) { + $event = $this->prepareEvent($event, $hint, $scope); + } if ($event === null) { return null; diff --git a/src/ClientReport/ClientReportAggregator.php b/src/ClientReport/ClientReportAggregator.php new file mode 100644 index 0000000000..8045a51b2b --- /dev/null +++ b/src/ClientReport/ClientReportAggregator.php @@ -0,0 +1,84 @@ + [ + * 'example-reason' => 10 + * ] + * ] + *``` + * + * @var array> + */ + private $reports = []; + + public function add(DataCategory $category, Reason $reason, int $quantity): void + { + $category = $category->getValue(); + $reason = $reason->getValue(); + if ($quantity <= 0) { + $client = HubAdapter::getInstance()->getClient(); + if ($client !== null) { + $logger = $client->getOptions()->getLoggerOrNullLogger(); + $logger->debug('Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', [ + 'category' => $category, + 'reason' => $reason, + 'quantity' => $quantity, + ]); + } + + return; + } + $this->reports[$category][$reason] = ($this->reports[$category][$reason] ?? 0) + $quantity; + } + + public function flush(): void + { + if (empty($this->reports)) { + return; + } + $reports = []; + foreach ($this->reports as $category => $reasons) { + foreach ($reasons as $reason => $quantity) { + $reports[] = new DiscardedEvent($category, $reason, $quantity); + } + } + $event = Event::createClientReport(); + $event->setClientReports($reports); + + $client = HubAdapter::getInstance()->getClient(); + + // Reset the client reports only if we successfully sent an event. If it fails it + // can be sent on the next flush, or it gets discarded anyway. + if ($client !== null && $client->captureEvent($event) !== null) { + $this->reports = []; + } + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } +} diff --git a/src/ClientReport/DiscardedEvent.php b/src/ClientReport/DiscardedEvent.php new file mode 100644 index 0000000000..c2982c36d7 --- /dev/null +++ b/src/ClientReport/DiscardedEvent.php @@ -0,0 +1,45 @@ +category = $category; + $this->reason = $reason; + $this->quantity = $quantity; + } + + public function getCategory(): string + { + return $this->category; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function getReason(): string + { + return $this->reason; + } +} diff --git a/src/ClientReport/Reason.php b/src/ClientReport/Reason.php new file mode 100644 index 0000000000..3c66bf7d49 --- /dev/null +++ b/src/ClientReport/Reason.php @@ -0,0 +1,102 @@ + + */ + private static $instances = []; + + public function __construct(string $value) + { + $this->value = $value; + } + + public static function queueOverflow(): self + { + return self::getInstance('queue_overflow'); + } + + public static function cacheOverflow(): self + { + return self::getInstance('cache_overflow'); + } + + public static function bufferOverflow(): self + { + return self::getInstance('buffer_overflow'); + } + + public static function ratelimitBackoff(): self + { + return self::getInstance('ratelimit_backoff'); + } + + public static function networkError(): self + { + return self::getInstance('network_error'); + } + + public static function sampleRate(): self + { + return self::getInstance('sample_rate'); + } + + public static function beforeSend(): self + { + return self::getInstance('before_send'); + } + + public static function eventProcessor(): self + { + return self::getInstance('event_processor'); + } + + public static function sendError(): self + { + return self::getInstance('send_error'); + } + + public static function internalSdkError(): self + { + return self::getInstance('internal_sdk_error'); + } + + public static function insufficientData(): self + { + return self::getInstance('insufficient_data'); + } + + public static function backpressure(): self + { + return self::getInstance('backpressure'); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString() + { + return $this->value; + } + + private static function getInstance(string $value): self + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } +} diff --git a/src/Event.php b/src/Event.php index a7563a54f2..93c2037423 100644 --- a/src/Event.php +++ b/src/Event.php @@ -5,6 +5,7 @@ namespace Sentry; use Sentry\Attachment\Attachment; +use Sentry\ClientReport\DiscardedEvent; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Logs\Log; @@ -217,6 +218,11 @@ final class Event */ private $attachments = []; + /** + * @var DiscardedEvent[] + */ + private $clientReports = []; + private function __construct(?EventId $eventId, EventType $eventType) { $this->id = $eventId ?? EventId::generate(); @@ -259,6 +265,11 @@ public static function createMetrics(?EventId $eventId = null): self return new self($eventId, EventType::metrics()); } + public static function createClientReport(?EventId $eventId = null): self + { + return new self($eventId, EventType::clientReport()); + } + /** * Gets the ID of this event. */ @@ -985,4 +996,22 @@ public function setAttachments(array $attachments): void { $this->attachments = $attachments; } + + /** + * @param DiscardedEvent[] $clientReports + */ + public function setClientReports(array $clientReports): self + { + $this->clientReports = $clientReports; + + return $this; + } + + /** + * @return DiscardedEvent[] + */ + public function getClientReports(): array + { + return $this->clientReports; + } } diff --git a/src/EventType.php b/src/EventType.php index ec7d6b0d2d..c5502e35e0 100644 --- a/src/EventType.php +++ b/src/EventType.php @@ -52,6 +52,11 @@ public static function metrics(): self return self::getInstance('trace_metric'); } + public static function clientReport(): self + { + return self::getInstance('client_report'); + } + /** * List of all cases on the enum. * @@ -65,6 +70,7 @@ public static function cases(): array self::checkIn(), self::logs(), self::metrics(), + self::clientReport(), ]; } @@ -73,12 +79,21 @@ public function requiresEventId(): bool switch ($this) { case self::metrics(): case self::logs(): + case self::clientReport(): return false; default: return true; } } + /** + * Returns false if rate limiting should not be applied. + */ + public function requiresRateLimiting(): bool + { + return $this !== self::clientReport(); + } + public function __toString(): string { return $this->value; diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index fc08233734..52006ee315 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -22,6 +22,13 @@ class HttpClient implements HttpClientInterface */ protected $sdkVersion; + /** + * Either a persistent share handle or a regular share handle, or null if no share handle can be obtained. + * + * @var object|resource|null + */ + private $shareHandle; + public function __construct(string $sdkIdentifier, string $sdkVersion) { $this->sdkIdentifier = $sdkIdentifier; @@ -72,6 +79,12 @@ public function sendRequest(Request $request, Options $options): Response curl_setopt($curlHandle, \CURLOPT_RETURNTRANSFER, true); curl_setopt($curlHandle, \CURLOPT_HEADERFUNCTION, $responseHeaderCallback); curl_setopt($curlHandle, \CURLOPT_HTTP_VERSION, \CURL_HTTP_VERSION_1_1); + if ($options->isShareHandleEnabled()) { + $shareHandle = $this->getShareHandle(); + if ($shareHandle !== null) { + curl_setopt($curlHandle, \CURLOPT_SHARE, $shareHandle); + } + } $httpSslVerifyPeer = $options->getHttpSslVerifyPeer(); if (!$httpSslVerifyPeer) { @@ -125,4 +138,51 @@ public function sendRequest(Request $request, Options $options): Response return new Response($statusCode, $responseHeaders, $error); } + + /** + * Initializes a share handle for CURL requests. If available, it will always try to use a persistent + * share handle first and fall back to a regular share handle in case it's unavailable. + * + * @return object|resource|null a share handle or null if none could be created + */ + private function getShareHandle() + { + if ($this->shareHandle !== null) { + return $this->shareHandle; + } + if (\function_exists('curl_share_init_persistent')) { + $shareOptions = [\CURL_LOCK_DATA_DNS]; + if (\defined('CURL_LOCK_DATA_CONNECT')) { + $shareOptions[] = \CURL_LOCK_DATA_CONNECT; + } + if (\defined('CURL_LOCK_DATA_SSL_SESSION')) { + $shareOptions[] = \CURL_LOCK_DATA_SSL_SESSION; + } + try { + $this->shareHandle = curl_share_init_persistent($shareOptions); + } catch (\Throwable $throwable) { + // don't crash if the share handle cannot be created + $this->shareHandle = null; + } + } + + // If the persistent share handle cannot be created or doesn't exist + if ($this->shareHandle === null) { + try { + $this->shareHandle = curl_share_init(); + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS); + if (\defined('CURL_LOCK_DATA_CONNECT')) { + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT); + } + if (\defined('CURL_LOCK_DATA_SSL_SESSION')) { + curl_share_setopt($this->shareHandle, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION); + } + } catch (\Throwable $throwable) { + // don't crash if the share handle cannot be created + $this->shareHandle = null; + } + } + + return $this->shareHandle; + } } diff --git a/src/Logs/Logs.php b/src/Logs/Logs.php index 99bc34e439..f1e0065436 100644 --- a/src/Logs/Logs.php +++ b/src/Logs/Logs.php @@ -5,6 +5,7 @@ namespace Sentry\Logs; use Sentry\EventId; +use Sentry\SentrySdk; class Logs { @@ -13,14 +14,8 @@ class Logs */ private static $instance; - /** - * @var LogsAggregator - */ - private $aggregator; - private function __construct() { - $this->aggregator = new LogsAggregator(); } public static function getInstance(): self @@ -39,7 +34,7 @@ public static function getInstance(): self */ public function trace(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::trace(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::trace(), $message, $values, $attributes); } /** @@ -49,7 +44,7 @@ public function trace(string $message, array $values = [], array $attributes = [ */ public function debug(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::debug(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::debug(), $message, $values, $attributes); } /** @@ -59,7 +54,7 @@ public function debug(string $message, array $values = [], array $attributes = [ */ public function info(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::info(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::info(), $message, $values, $attributes); } /** @@ -69,7 +64,7 @@ public function info(string $message, array $values = [], array $attributes = [] */ public function warn(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::warn(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::warn(), $message, $values, $attributes); } /** @@ -79,7 +74,7 @@ public function warn(string $message, array $values = [], array $attributes = [] */ public function error(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::error(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::error(), $message, $values, $attributes); } /** @@ -89,7 +84,7 @@ public function error(string $message, array $values = [], array $attributes = [ */ public function fatal(string $message, array $values = [], array $attributes = []): void { - $this->aggregator->add(LogLevel::fatal(), $message, $values, $attributes); + $this->aggregator()->add(LogLevel::fatal(), $message, $values, $attributes); } /** @@ -97,7 +92,7 @@ public function fatal(string $message, array $values = [], array $attributes = [ */ public function flush(): ?EventId { - return $this->aggregator->flush(); + return $this->aggregator()->flush(); } /** @@ -107,6 +102,6 @@ public function flush(): ?EventId */ public function aggregator(): LogsAggregator { - return $this->aggregator; + return SentrySdk::getCurrentRuntimeContext()->getLogsAggregator(); } } diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 31df0a7351..5413cbf797 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -149,13 +149,13 @@ public function add( $this->logs[] = $log; } - public function flush(): ?EventId + public function flush(?HubInterface $hub = null): ?EventId { if (empty($this->logs)) { return null; } - $hub = SentrySdk::getCurrentHub(); + $hub = $hub ?? SentrySdk::getCurrentHub(); $event = Event::createLogs()->setLogs($this->logs); $this->logs = []; diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index a25df0b35b..46f015be0e 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -12,6 +12,7 @@ use Sentry\Metrics\Types\GaugeMetric; use Sentry\Metrics\Types\Metric; use Sentry\SentrySdk; +use Sentry\State\HubInterface; use Sentry\State\Scope; use Sentry\Unit; use Sentry\Util\RingBuffer; @@ -134,13 +135,13 @@ public function add( $this->metrics->push($metric); } - public function flush(): ?EventId + public function flush(?HubInterface $hub = null): ?EventId { if ($this->metrics->isEmpty()) { return null; } - $hub = SentrySdk::getCurrentHub(); + $hub = $hub ?? SentrySdk::getCurrentHub(); $event = Event::createMetrics()->setMetrics($this->metrics->drain()); return $hub->captureEvent($event); diff --git a/src/Metrics/TraceMetrics.php b/src/Metrics/TraceMetrics.php index a3ef4a0a0b..4eae90d388 100644 --- a/src/Metrics/TraceMetrics.php +++ b/src/Metrics/TraceMetrics.php @@ -8,6 +8,7 @@ use Sentry\Metrics\Types\CounterMetric; use Sentry\Metrics\Types\DistributionMetric; use Sentry\Metrics\Types\GaugeMetric; +use Sentry\SentrySdk; use Sentry\Unit; class TraceMetrics @@ -17,14 +18,8 @@ class TraceMetrics */ private static $instance; - /** - * @var MetricsAggregator - */ - private $aggregator; - public function __construct() { - $this->aggregator = new MetricsAggregator(); } public static function getInstance(): self @@ -46,7 +41,7 @@ public function count( array $attributes = [], ?Unit $unit = null ): void { - $this->aggregator->add( + $this->aggregator()->add( CounterMetric::TYPE, $name, $value, @@ -65,7 +60,7 @@ public function distribution( array $attributes = [], ?Unit $unit = null ): void { - $this->aggregator->add( + $this->aggregator()->add( DistributionMetric::TYPE, $name, $value, @@ -84,7 +79,7 @@ public function gauge( array $attributes = [], ?Unit $unit = null ): void { - $this->aggregator->add( + $this->aggregator()->add( GaugeMetric::TYPE, $name, $value, @@ -95,6 +90,11 @@ public function gauge( public function flush(): ?EventId { - return $this->aggregator->flush(); + return $this->aggregator()->flush(); + } + + private function aggregator(): MetricsAggregator + { + return SentrySdk::getCurrentRuntimeContext()->getMetricsAggregator(); } } diff --git a/src/Monolog/BreadcrumbHandler.php b/src/Monolog/BreadcrumbHandler.php index bb2b60ea06..fbda73c26d 100644 --- a/src/Monolog/BreadcrumbHandler.php +++ b/src/Monolog/BreadcrumbHandler.php @@ -54,13 +54,18 @@ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bub */ protected function write($record): void { + $datetime = $record['datetime'] ?? null; + $timestamp = $datetime instanceof \DateTimeInterface + ? $datetime->getTimestamp() + (int) $datetime->format('u') / 1000000 + : null; + $breadcrumb = new Breadcrumb( $this->getBreadcrumbLevel($record['level']), $this->getBreadcrumbType($record['level']), $record['channel'], $record['message'], ($record['context'] ?? []) + ($record['extra'] ?? []), - $record['datetime']->getTimestamp() + $timestamp ); $this->hub->addBreadcrumb($breadcrumb); diff --git a/src/Options.php b/src/Options.php index 4294efcbf6..86e03fefe0 100644 --- a/src/Options.php +++ b/src/Options.php @@ -586,19 +586,24 @@ public function setTracePropagationTargets(array $tracePropagationTargets): self } /** - * Returns whether strict trace propagation is enabled or not. + * Returns whether strict trace continuation is enabled or not. */ - public function isStrictTracePropagationEnabled(): bool + public function isStrictTraceContinuationEnabled(): bool { - return $this->options['strict_trace_propagation']; + /** + * @var bool $result + */ + $result = $this->options['strict_trace_continuation']; + + return $result; } /** - * Sets if strict trace propagation should be enabled or not. + * Sets if strict trace continuation should be enabled or not. */ - public function enableStrictTracePropagation(bool $strictTracePropagation): self + public function enableStrictTraceContinuation(bool $strictTraceContinuation): self { - return $this->updateOptions(['strict_trace_propagation' => $strictTracePropagation]); + return $this->updateOptions(['strict_trace_continuation' => $strictTraceContinuation]); } /** @@ -863,6 +868,33 @@ public function setEnableHttpCompression(bool $enabled): self return $this->updateOptions(['http_compression' => $enabled]); } + /** + * Returns whether a shared curl handle should be used or not. + * + * For PHP 8.5 and above, this will use the persistent curl handle. For previous PHP versions, it will use the + * regular share handle. + */ + public function isShareHandleEnabled(): bool + { + /** + * @var bool $shareHandleEnabled + */ + $shareHandleEnabled = $this->options['http_enable_curl_share_handle']; + + return $shareHandleEnabled; + } + + /** + * Sets whether the persistent curl handle should be used or not. + * + * For PHP 8.5 and above, this will use the persistent curl handle. For previous PHP versions, it will use the + * regular share handle. + */ + public function setEnableShareHandle(bool $enabled): self + { + return $this->updateOptions(['http_enable_curl_share_handle' => $enabled]); + } + /** * Gets whether the silenced errors should be captured or not. * @@ -995,6 +1027,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('ignore_exceptions', 'string[]'); $resolver->setAllowedTypes('ignore_transactions', 'string[]'); $resolver->setAllowedTypes('trace_propagation_targets', ['null', 'string[]']); + $resolver->setAllowedTypes('strict_trace_continuation', 'bool'); $resolver->setAllowedTypes('strict_trace_propagation', 'bool'); $resolver->setAllowedTypes('tags', 'string[]'); $resolver->setAllowedTypes('error_types', ['null', 'int']); @@ -1012,6 +1045,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('http_ssl_verify_peer', 'bool'); $resolver->setAllowedTypes('http_ssl_native_ca', 'bool'); $resolver->setAllowedTypes('http_compression', 'bool'); + $resolver->setAllowedTypes('http_enable_curl_share_handle', 'bool'); $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); $resolver->setAllowedTypes('max_request_body_size', 'string'); $resolver->setAllowedTypes('class_serializers', 'array'); @@ -1075,6 +1109,7 @@ private function configureOptions(OptionsResolver $resolver): void return $metric; }, 'trace_propagation_targets' => null, + 'strict_trace_continuation' => false, 'strict_trace_propagation' => false, 'tags' => [], 'error_types' => null, @@ -1094,6 +1129,7 @@ private function configureOptions(OptionsResolver $resolver): void 'http_ssl_verify_peer' => true, 'http_ssl_native_ca' => false, 'http_compression' => true, + 'http_enable_curl_share_handle' => true, 'capture_silenced_errors' => false, 'max_request_body_size' => 'medium', 'class_serializers' => [], diff --git a/src/SentrySdk.php b/src/SentrySdk.php index dc08cfaf49..0ef0e5270c 100644 --- a/src/SentrySdk.php +++ b/src/SentrySdk.php @@ -8,6 +8,8 @@ use Sentry\Metrics\TraceMetrics; use Sentry\State\Hub; use Sentry\State\HubInterface; +use Sentry\State\RuntimeContext; +use Sentry\State\RuntimeContextManager; /** * This class is the main entry point for all the most common SDK features. @@ -17,10 +19,15 @@ final class SentrySdk { /** - * @var HubInterface|null The current hub + * @var HubInterface|null The baseline hub */ private static $currentHub; + /** + * @var RuntimeContextManager|null + */ + private static $runtimeContextManager; + /** * Constructor. */ @@ -38,6 +45,7 @@ public static function init(?ClientInterface $client = null): HubInterface $client = new NoOpClient(); } self::$currentHub = new Hub($client); + self::$runtimeContextManager = new RuntimeContextManager(self::$currentHub); return self::$currentHub; } @@ -48,25 +56,83 @@ public static function init(?ClientInterface $client = null): HubInterface */ public static function getCurrentHub(): HubInterface { - if (self::$currentHub === null) { - self::$currentHub = new Hub(new NoOpClient()); - } - - return self::$currentHub; + return self::getRuntimeContextManager()->getCurrentHub(); } /** * Sets the current hub. * + * If called while an explicit runtime context is active, the hub update is + * scoped to that active context only. Otherwise, it updates the baseline + * hub used by the global fallback context and future contexts. + * * @param HubInterface $hub The hub to set */ public static function setCurrentHub(HubInterface $hub): HubInterface { - self::$currentHub = $hub; + $wasSetOnActiveRuntimeContext = self::getRuntimeContextManager()->setCurrentHub($hub); + + if (!$wasSetOnActiveRuntimeContext) { + self::$currentHub = $hub; + } return $hub; } + public static function startContext(): void + { + self::getRuntimeContextManager()->startContext(); + } + + public static function endContext(?int $timeout = null): void + { + self::getRuntimeContextManager()->endContext($timeout); + } + + /** + * Executes the given callback within an isolated context. + * + * If a context is already active for the current execution key, this method + * reuses it and only executes the callback. + * + * @param callable $callback The callback to execute + * + * @psalm-template T + * + * @psalm-param callable(): T $callback + * + * @return mixed + * + * @psalm-return T + */ + public static function withContext(callable $callback, ?int $timeout = null) + { + $runtimeContextManager = self::getRuntimeContextManager(); + $startedNewContext = !$runtimeContextManager->hasActiveContext(); + + if ($startedNewContext) { + $runtimeContextManager->startContext(); + } + + try { + return $callback(); + } finally { + if ($startedNewContext) { + $runtimeContextManager->endContext($timeout); + } + } + } + + /** + * Gets the current runtime-local context. + * + * @internal + */ + public static function getCurrentRuntimeContext(): RuntimeContext + { + return self::getRuntimeContextManager()->getCurrentContext(); + } + /** * Flushes all buffered telemetry data. * @@ -81,5 +147,24 @@ public static function flush(): void { Logs::getInstance()->flush(); TraceMetrics::getInstance()->flush(); + + $client = self::getCurrentHub()->getClient(); + + if ($client !== null) { + $client->flush(); + } + } + + private static function getRuntimeContextManager(): RuntimeContextManager + { + if (self::$currentHub === null) { + self::$currentHub = new Hub(new NoOpClient()); + } + + if (self::$runtimeContextManager === null) { + self::$runtimeContextManager = new RuntimeContextManager(self::$currentHub); + } + + return self::$runtimeContextManager; } } diff --git a/src/Serializer/EnvelopItems/ClientReportItem.php b/src/Serializer/EnvelopItems/ClientReportItem.php new file mode 100644 index 0000000000..c43a9ee0b4 --- /dev/null +++ b/src/Serializer/EnvelopItems/ClientReportItem.php @@ -0,0 +1,31 @@ +getClientReports(); + + $headers = ['type' => 'client_report']; + $body = [ + 'timestamp' => $event->getTimestamp(), + 'discarded_events' => array_map(static function (DiscardedEvent $report) { + return [ + 'category' => $report->getCategory(), + 'reason' => $report->getReason(), + 'quantity' => $report->getQuantity(), + ]; + }, $reports), + ]; + + return \sprintf("%s\n%s", JSON::encode($headers), JSON::encode($body)); + } +} diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index 8b89c55743..9916568dd8 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -9,6 +9,7 @@ use Sentry\Options; use Sentry\Serializer\EnvelopItems\AttachmentItem; use Sentry\Serializer\EnvelopItems\CheckInItem; +use Sentry\Serializer\EnvelopItems\ClientReportItem; use Sentry\Serializer\EnvelopItems\EventItem; use Sentry\Serializer\EnvelopItems\LogsItem; use Sentry\Serializer\EnvelopItems\MetricsItem; @@ -40,23 +41,26 @@ public function __construct(Options $options) */ public function serialize(Event $event): string { - // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers - $envelopeHeader = [ - 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), - 'dsn' => (string) $this->options->getDsn(), - 'sdk' => $event->getSdkPayload(), - ]; + $envelopeHeader = null; + if ($event->getType() !== EventType::clientReport()) { + // @see https://develop.sentry.dev/sdk/envelopes/#envelope-headers + $envelopeHeader = [ + 'sent_at' => gmdate('Y-m-d\TH:i:s\Z'), + 'dsn' => (string) $this->options->getDsn(), + 'sdk' => $event->getSdkPayload(), + ]; - if ($event->getType()->requiresEventId()) { - $envelopeHeader['event_id'] = (string) $event->getId(); - } + if ($event->getType()->requiresEventId()) { + $envelopeHeader['event_id'] = (string) $event->getId(); + } - $dynamicSamplingContext = $event->getSdkMetadata('dynamic_sampling_context'); - if ($dynamicSamplingContext instanceof DynamicSamplingContext) { - $entries = $dynamicSamplingContext->getEntries(); + $dynamicSamplingContext = $event->getSdkMetadata('dynamic_sampling_context'); + if ($dynamicSamplingContext instanceof DynamicSamplingContext) { + $entries = $dynamicSamplingContext->getEntries(); - if (!empty($entries)) { - $envelopeHeader['trace'] = $entries; + if (!empty($entries)) { + $envelopeHeader['trace'] = $entries; + } } } @@ -87,6 +91,13 @@ public function serialize(Event $event): string case EventType::metrics(): $items[] = MetricsItem::toEnvelopeItem($event); break; + case EventType::clientReport(): + $items[] = ClientReportItem::toEnvelopeItem($event); + break; + } + + if ($envelopeHeader === null) { + return \sprintf("{}\n%s", implode("\n", array_filter($items))); } return \sprintf("%s\n%s", JSON::encode($envelopeHeader), implode("\n", array_filter($items))); diff --git a/src/State/RuntimeContext.php b/src/State/RuntimeContext.php new file mode 100644 index 0000000000..6910cae608 --- /dev/null +++ b/src/State/RuntimeContext.php @@ -0,0 +1,72 @@ +id = $id; + $this->hub = $hub; + $this->logsAggregator = new LogsAggregator(); + $this->metricsAggregator = new MetricsAggregator(); + } + + public function getId(): string + { + return $this->id; + } + + public function getHub(): HubInterface + { + return $this->hub; + } + + public function setHub(HubInterface $hub): void + { + $this->hub = $hub; + } + + public function getLogsAggregator(): LogsAggregator + { + return $this->logsAggregator; + } + + public function getMetricsAggregator(): MetricsAggregator + { + return $this->metricsAggregator; + } +} diff --git a/src/State/RuntimeContextManager.php b/src/State/RuntimeContextManager.php new file mode 100644 index 0000000000..43b46fb65b --- /dev/null +++ b/src/State/RuntimeContextManager.php @@ -0,0 +1,281 @@ + + */ + private $activeContexts = []; + + /** + * @var array + */ + private $executionContextToRuntimeContext = []; + + public function __construct(HubInterface $baseHub) + { + $this->baseHub = $baseHub; + $this->globalContext = null; + } + + /** + * Sets the current hub with context-aware behavior. + * + * If a runtime context is active for the current execution key, the hub is + * updated only for that active context. Otherwise, the baseline/global hub + * template is updated. + * + * @return bool Whether the hub was set on an active runtime context + */ + public function setCurrentHub(HubInterface $hub): bool + { + $executionContextKey = $this->getExecutionContextKey(); + + if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) { + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + $this->activeContexts[$runtimeContextId]->setHub($hub); + + return true; + } + + $this->baseHub = $hub; + + if ($this->globalContext !== null) { + $this->globalContext->setHub($hub); + } + + return false; + } + + public function getCurrentHub(): HubInterface + { + return $this->getCurrentContext()->getHub(); + } + + public function getCurrentContext(): RuntimeContext + { + $executionContextKey = $this->getExecutionContextKey(); + + if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) { + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + + return $this->activeContexts[$runtimeContextId]; + } + + return $this->getGlobalContext(); + } + + public function hasActiveContext(): bool + { + return $this->hasActiveContextForExecutionContextKey($this->getExecutionContextKey()); + } + + /** + * Starts an isolated context for the current execution key. + */ + public function startContext(): void + { + $executionContextKey = $this->getExecutionContextKey(); + + if ($this->hasActiveContextForExecutionContextKey($executionContextKey)) { + // Nested start calls for the same execution key should be a no-op. + return; + } + + $this->createContextForExecutionContextKey($executionContextKey); + } + + /** + * Ends and flushes the active context for the current execution key. + * + * When no context is active for the key this is a no-op. + */ + public function endContext(?int $timeout = null): void + { + $executionContextKey = $this->getExecutionContextKey(); + + if (!$this->hasActiveContextForExecutionContextKey($executionContextKey)) { + return; + } + + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + unset($this->executionContextToRuntimeContext[$executionContextKey]); + + $this->removeContextById($runtimeContextId, $timeout); + } + + private function createContextForExecutionContextKey(string $executionContextKey): void + { + $runtimeContextId = $this->generateRuntimeContextId(); + $runtimeContext = new RuntimeContext($runtimeContextId, $this->createHubFromBaseHub()); + + $this->activeContexts[$runtimeContextId] = $runtimeContext; + $this->executionContextToRuntimeContext[$executionContextKey] = $runtimeContextId; + } + + private function removeContextById(string $runtimeContextId, ?int $timeout = null): void + { + if (!isset($this->activeContexts[$runtimeContextId])) { + return; + } + + $runtimeContext = $this->activeContexts[$runtimeContextId]; + unset($this->activeContexts[$runtimeContextId]); + // Remove any key mappings that may still reference this context. + $this->removeExecutionContextMappingsForRuntimeContext($runtimeContextId); + + $logger = $this->getLoggerFromHub($runtimeContext->getHub()); + + $this->flushRuntimeContextResources($runtimeContext, $timeout, $logger); + } + + private function flushRuntimeContextResources(RuntimeContext $runtimeContext, ?int $timeout, LoggerInterface $logger): void + { + $hub = $runtimeContext->getHub(); + + // captureEvent can throw before transport send (for example from scope event processors + // or before_send callbacks), so we isolate failures and continue flushing other resources. + try { + $runtimeContext->getLogsAggregator()->flush($hub); + } catch (\Throwable $exception) { + $logger->error('Failed to flush logs while ending a runtime context.', [ + 'exception' => $exception, + 'runtime_context_id' => $runtimeContext->getId(), + ]); + } + + // Keep metrics flush independent from logs flush so one bad callback does not block the rest. + try { + $runtimeContext->getMetricsAggregator()->flush($hub); + } catch (\Throwable $exception) { + $logger->error('Failed to flush trace metrics while ending a runtime context.', [ + 'exception' => $exception, + 'runtime_context_id' => $runtimeContext->getId(), + ]); + } + + $client = $hub->getClient(); + + if ($client === null) { + return; + } + + // Custom transports may throw from close(); endContext must stay best-effort and non-fatal. + try { + $client->flush($timeout); + } catch (\Throwable $exception) { + $logger->error('Failed to flush the client transport while ending a runtime context.', [ + 'exception' => $exception, + 'runtime_context_id' => $runtimeContext->getId(), + ]); + } + } + + private function removeExecutionContextMappingsForRuntimeContext(string $runtimeContextId): void + { + foreach ($this->executionContextToRuntimeContext as $executionContextKey => $mappedRuntimeContextId) { + if ($mappedRuntimeContextId === $runtimeContextId) { + unset($this->executionContextToRuntimeContext[$executionContextKey]); + } + } + } + + private function hasActiveContextForExecutionContextKey(string $executionContextKey): bool + { + if (!isset($this->executionContextToRuntimeContext[$executionContextKey])) { + return false; + } + + $runtimeContextId = $this->executionContextToRuntimeContext[$executionContextKey]; + + if (!isset($this->activeContexts[$runtimeContextId])) { + // Mapping points to a context that was already evicted/ended; drop the stale index entry. + unset($this->executionContextToRuntimeContext[$executionContextKey]); + + return false; + } + + return true; + } + + private function createHubFromBaseHub(): HubInterface + { + if (!$this->baseHub instanceof Hub) { + return new Hub($this->baseHub->getClient()); + } + + $clonedScope = null; + + $this->baseHub->configureScope(static function (Scope $scope) use (&$clonedScope): void { + $clonedScope = clone $scope; + // Do not inherit active traces into a new runtime context. + $clonedScope->setSpan(null); + $clonedScope->setPropagationContext(PropagationContext::fromDefaults()); + }); + + return new Hub($this->baseHub->getClient(), $clonedScope ?? new Scope()); + } + + private function getLoggerFromHub(HubInterface $hub): LoggerInterface + { + $client = $hub->getClient(); + + if ($client === null) { + return new NullLogger(); + } + + return $client->getOptions()->getLoggerOrNullLogger(); + } + + private function generateRuntimeContextId(): string + { + return \sprintf('%s-%d', str_replace('.', '', uniqid('', true)), mt_rand()); + } + + private function getExecutionContextKey(): string + { + // All supported runtime modes currently use a process-local execution key. + return self::PROCESS_EXECUTION_CONTEXT_KEY; + } + + private function getGlobalContext(): RuntimeContext + { + if ($this->globalContext === null) { + // Lazy fallback keeps baseline behavior when users do not opt into explicit context lifecycle. + $this->globalContext = new RuntimeContext('global', $this->baseHub); + } + + return $this->globalContext; + } +} diff --git a/src/Tracing/DynamicSamplingContext.php b/src/Tracing/DynamicSamplingContext.php index 2f6f3f3adb..100bcba67b 100644 --- a/src/Tracing/DynamicSamplingContext.php +++ b/src/Tracing/DynamicSamplingContext.php @@ -166,22 +166,7 @@ public static function fromTransaction(Transaction $transaction, HubInterface $h $client = $hub->getClient(); - $options = $client->getOptions(); - - if ($options->getDsn() !== null && $options->getDsn()->getPublicKey() !== null) { - $samplingContext->set('public_key', $options->getDsn()->getPublicKey()); - } - if ($options->getDsn() !== null && $options->getDsn()->getOrgId() !== null) { - $samplingContext->set('org_id', (string) $options->getDsn()->getOrgId()); - } - - if ($options->getRelease() !== null) { - $samplingContext->set('release', $options->getRelease()); - } - - if ($options->getEnvironment() !== null) { - $samplingContext->set('environment', $options->getEnvironment()); - } + self::setOrgOptions($client->getOptions(), $samplingContext); if ($transaction->getSampled() !== null) { $samplingContext->set('sampled', $transaction->getSampled() ? 'true' : 'false'); @@ -199,18 +184,29 @@ public static function fromTransaction(Transaction $transaction, HubInterface $h public static function fromOptions(Options $options, Scope $scope): self { $samplingContext = new self(); - $samplingContext->set('trace_id', (string) $scope->getPropagationContext()->getTraceId()); + $samplingContext->set('trace_id', (string)$scope->getPropagationContext()->getTraceId()); $samplingContext->set('sample_rand', (string) $scope->getPropagationContext()->getSampleRand()); if ($options->getTracesSampleRate() !== null) { $samplingContext->set('sample_rate', (string) $options->getTracesSampleRate()); } + self::setOrgOptions($options, $samplingContext); + + $samplingContext->freeze(); + + return $samplingContext; + } + + private static function setOrgOptions(Options $options, DynamicSamplingContext $samplingContext): void + { if ($options->getDsn() !== null && $options->getDsn()->getPublicKey() !== null) { $samplingContext->set('public_key', $options->getDsn()->getPublicKey()); } - if ($options->getDsn() !== null && $options->getDsn()->getOrgId() !== null) { + if ($options->getOrgId() !== null) { + $samplingContext->set('org_id', (string) $options->getOrgId()); + } elseif ($options->getDsn() !== null && $options->getDsn()->getOrgId() !== null) { $samplingContext->set('org_id', (string) $options->getDsn()->getOrgId()); } @@ -221,10 +217,6 @@ public static function fromOptions(Options $options, Scope $scope): self if ($options->getEnvironment() !== null) { $samplingContext->set('environment', $options->getEnvironment()); } - - $samplingContext->freeze(); - - return $samplingContext; } /** diff --git a/src/Tracing/Traits/TraceHeaderParserTrait.php b/src/Tracing/Traits/TraceHeaderParserTrait.php index 56e002d68b..1a5c0f7c08 100644 --- a/src/Tracing/Traits/TraceHeaderParserTrait.php +++ b/src/Tracing/Traits/TraceHeaderParserTrait.php @@ -4,6 +4,7 @@ namespace Sentry\Tracing\Traits; +use Sentry\SentrySdk; use Sentry\Tracing\DynamicSamplingContext; use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; @@ -65,22 +66,29 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin $samplingContext = DynamicSamplingContext::fromHeader($baggage); - if ($hasSentryTrace && !$samplingContext->hasEntries()) { + if ($hasSentryTrace && !self::shouldContinueTrace($samplingContext)) { + $result['traceId'] = null; + $result['parentSpanId'] = null; + $result['parentSampled'] = null; + + return $result; + } + + if ($hasSentryTrace) { // The request comes from an old SDK which does not support Dynamic Sampling. // Propagate the Dynamic Sampling Context as is, but frozen, even without sentry-* entries. - $samplingContext->freeze(); - $result['dynamicSamplingContext'] = $samplingContext; - } + if (!$samplingContext->hasEntries()) { + $samplingContext->freeze(); + } - if ($hasSentryTrace && $samplingContext->hasEntries()) { // The baggage header contains Dynamic Sampling Context data from an upstream SDK. // Propagate this Dynamic Sampling Context. $result['dynamicSamplingContext'] = $samplingContext; - } - // Store the propagated traces sample rate - if ($samplingContext->has('sample_rate')) { - $result['parentSamplingRate'] = (float) $samplingContext->get('sample_rate'); + // Store the propagated traces sample rate + if ($samplingContext->has('sample_rate')) { + $result['parentSamplingRate'] = (float) $samplingContext->get('sample_rate'); + } } // Store the propagated trace sample rand or generate a new one @@ -103,4 +111,51 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin return $result; } + + private static function shouldContinueTrace(DynamicSamplingContext $samplingContext): bool + { + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + if ($client === null) { + return true; + } + + $options = $client->getOptions(); + $clientOrgId = $options->getOrgId(); + if ($clientOrgId === null && $options->getDsn() !== null) { + $clientOrgId = $options->getDsn()->getOrgId(); + } + + $baggageOrgId = $samplingContext->get('org_id'); + $logger = $options->getLoggerOrNullLogger(); + + // both org IDs are set but are not equals + if ($clientOrgId !== null && $baggageOrgId !== null && ((string) $clientOrgId !== $baggageOrgId)) { + $logger->debug( + \sprintf( + "Starting a new trace because org IDs don't match (incoming baggage org_id: %s, SDK org_id: %s)", + $baggageOrgId, + $clientOrgId + ) + ); + + return false; + } + + // One org ID is not set and strict trace continuation is enabled + if ($options->isStrictTraceContinuationEnabled() && ($clientOrgId === null) !== ($baggageOrgId === null)) { + $logger->debug( + \sprintf( + 'Starting a new trace because strict trace continuation is enabled and one org ID is missing (incoming baggage org_id: %s, SDK org_id: %s)', + $baggageOrgId !== null ? $baggageOrgId : 'none', + $clientOrgId !== null ? (string) $clientOrgId : 'none' + ) + ); + + return false; + } + + return true; + } } diff --git a/src/Transport/DataCategory.php b/src/Transport/DataCategory.php new file mode 100644 index 0000000000..b2ee0884e9 --- /dev/null +++ b/src/Transport/DataCategory.php @@ -0,0 +1,83 @@ + + */ + private static $instances = []; + + public function __construct(string $value) + { + $this->value = $value; + } + + public static function error(): self + { + return self::getInstance('error'); + } + + public static function transaction(): self + { + return self::getInstance('transaction'); + } + + // TODO: not sure if this should be called monitor or checkIn. + public static function checkIn(): self + { + return self::getInstance('monitor'); + } + + public static function logItem(): self + { + return self::getInstance('log_item'); + } + + public static function logBytes(): self + { + return self::getInstance('log_byte'); + } + + public static function profile(): self + { + return self::getInstance('profile'); + } + + public static function metric(): self + { + return self::getInstance('trace_metric'); + } + + public static function internal(): self + { + return self::getInstance('internal'); + } + + public function getValue(): string + { + return $this->value; + } + + public function __toString() + { + return $this->value; + } + + private static function getInstance(string $value): self + { + if (!isset(self::$instances[$value])) { + self::$instances[$value] = new self($value); + } + + return self::$instances[$value]; + } +} diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 12666ebd4b..189d7eed8b 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -91,26 +91,28 @@ public function send(Event $event): Result $this->logger->info(\sprintf('Sending %s to %s.', $eventDescription, $targetDescription), ['event' => $event]); $eventType = $event->getType(); - if ($this->rateLimiter->isRateLimited((string) $eventType)) { - $this->logger->warning( - \sprintf('Rate limit exceeded for sending requests of type "%s".', (string) $eventType), - ['event' => $event] - ); - - return new Result(ResultStatus::rateLimit()); - } - - // Since profiles are attached to transaction we have to check separately if they are rate limited. - // We can do this after transactions have been checked because if transactions are rate limited, - // so are profiles but not the other way around. - if ($event->getSdkMetadata('profile') !== null) { - if ($this->rateLimiter->isRateLimited(RateLimiter::DATA_CATEGORY_PROFILE)) { - // Just remove profiling data so the normal transaction can be sent. - $event->setSdkMetadata('profile', null); + if ($eventType->requiresRateLimiting()) { + if ($this->rateLimiter->isRateLimited((string) $eventType)) { $this->logger->warning( - 'Rate limit exceeded for sending requests of type "profile". The profile has been dropped.', + \sprintf('Rate limit exceeded for sending requests of type "%s".', (string) $eventType), ['event' => $event] ); + + return new Result(ResultStatus::rateLimit()); + } + + // Since profiles are attached to transaction we have to check separately if they are rate limited. + // We can do this after transactions have been checked because if transactions are rate limited, + // so are profiles but not the other way around. + if ($event->getSdkMetadata('profile') !== null) { + if ($this->rateLimiter->isRateLimited(RateLimiter::DATA_CATEGORY_PROFILE)) { + // Just remove profiling data so the normal transaction can be sent. + $event->setSdkMetadata('profile', null); + $this->logger->warning( + 'Rate limit exceeded for sending requests of type "profile". The profile has been dropped.', + ['event' => $event] + ); + } } } diff --git a/src/Transport/ResultStatus.php b/src/Transport/ResultStatus.php index 605b3b689c..bc71860039 100644 --- a/src/Transport/ResultStatus.php +++ b/src/Transport/ResultStatus.php @@ -57,6 +57,15 @@ public static function success(): self return self::getInstance('SUCCESS'); } + /** + * Returns an instance of this enum representing the fact that the event + * failed to be sent because the content was too large. + */ + public static function contentTooLarge(): self + { + return self::getInstance('CONTENT_TOO_LARGE'); + } + /** * Returns an instance of this enum representing the fact that the event * failed to be sent because of API rate limiting. @@ -94,6 +103,8 @@ public static function createFromHttpStatusCode(int $statusCode): self switch (true) { case $statusCode >= 200 && $statusCode < 300: return self::success(); + case $statusCode === 413: + return self::contentTooLarge(); case $statusCode === 429: return self::rateLimit(); case $statusCode >= 400 && $statusCode < 500: diff --git a/src/UserDataBag.php b/src/UserDataBag.php index 922b52c7f6..5fecb478c9 100644 --- a/src/UserDataBag.php +++ b/src/UserDataBag.php @@ -188,8 +188,23 @@ public function getIpAddress(): ?string */ public function setIpAddress(?string $ipAddress): self { - if ($ipAddress !== null && filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) { - throw new \InvalidArgumentException(\sprintf('The "%s" value is not a valid IP address.', $ipAddress)); + if ($ipAddress !== null) { + // Strip brackets from IPv6 addresses (e.g. [::1] -> ::1) + if (strpos($ipAddress, '[') === 0 && substr($ipAddress, -1) === ']') { + $ipAddress = substr($ipAddress, 1, -1); + } + + if (filter_var($ipAddress, \FILTER_VALIDATE_IP) === false) { + $client = SentrySdk::getCurrentHub()->getClient(); + + if ($client !== null) { + $client->getOptions()->getLoggerOrNullLogger()->debug( + \sprintf('The "%s" value is not a valid IP address.', $ipAddress) + ); + } + + return $this; + } } $this->ipAddress = $ipAddress; diff --git a/src/functions.php b/src/functions.php index dbdee43818..c9471ace0e 100644 --- a/src/functions.php +++ b/src/functions.php @@ -40,6 +40,7 @@ * http_proxy_authentication?: string|null, * http_ssl_verify_peer?: bool, * http_timeout?: int|float, + * http_enable_curl_share_handle?: bool, * ignore_exceptions?: array, * ignore_transactions?: array, * in_app_exclude?: array, @@ -57,7 +58,8 @@ * send_default_pii?: bool, * server_name?: string, * spotlight?: bool, - * strict_trace_propagation?: bool, + * spotlight_url?: string, + * strict_trace_continuation?: bool, * tags?: array, * trace_propagation_targets?: array|null, * traces_sample_rate?: float|int|null, @@ -212,6 +214,37 @@ function withScope(callable $callback) return SentrySdk::getCurrentHub()->withScope($callback); } +function startContext(): void +{ + SentrySdk::startContext(); +} + +function endContext(?int $timeout = null): void +{ + SentrySdk::endContext($timeout); +} + +/** + * Executes the given callback within an isolated context. + * + * If a context is already active for the current execution key, it is reused. + * + * @param callable $callback The callback to execute + * @param int|null $timeout The maximum number of seconds to wait while flushing the client transport + * + * @psalm-template T + * + * @psalm-param callable(): T $callback + * + * @return mixed + * + * @psalm-return T + */ +function withContext(callable $callback, ?int $timeout = null) +{ + return SentrySdk::withContext($callback, $timeout); +} + /** * Starts a new `Transaction` and returns it. This is the entry point to manual * tracing instrumentation. @@ -330,13 +363,32 @@ function getBaggage(): string */ function continueTrace(string $sentryTrace, string $baggage): TransactionContext { + // With the new `strict_trace_continuation`, it's possible that we start two new + // traces if we parse the TransactionContext and PropagationContext from the same + // headers. To make sure the trace is the same, we will create one transaction + // context from headers and copy relevant information over. + $transactionContext = TransactionContext::fromHeaders($sentryTrace, $baggage); + $propagationContext = PropagationContext::fromDefaults(); + $metadata = $transactionContext->getMetadata(); + + $traceId = $transactionContext->getTraceId() ?? $propagationContext->getTraceId(); + $transactionContext->setTraceId($traceId); + $propagationContext->setTraceId($traceId); + + $propagationContext->setParentSpanId($transactionContext->getParentSpanId()); + $propagationContext->setSampleRand($metadata->getSampleRand()); + + $dynamicSamplingContext = $metadata->getDynamicSamplingContext(); + if ($dynamicSamplingContext !== null) { + $propagationContext->setDynamicSamplingContext($dynamicSamplingContext); + } + $hub = SentrySdk::getCurrentHub(); - $hub->configureScope(static function (Scope $scope) use ($sentryTrace, $baggage) { - $propagationContext = PropagationContext::fromHeaders($sentryTrace, $baggage); + $hub->configureScope(static function (Scope $scope) use ($propagationContext): void { $scope->setPropagationContext($propagationContext); }); - return TransactionContext::fromHeaders($sentryTrace, $baggage); + return $transactionContext; } /** @@ -347,7 +399,7 @@ function logger(): Logs return Logs::getInstance(); } -function trace_metrics(): TraceMetrics +function metrics(): TraceMetrics { return TraceMetrics::getInstance(); } diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php new file mode 100644 index 0000000000..ce82c4d50a --- /dev/null +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -0,0 +1,118 @@ +bindClient(new Client(new Options([ + 'logger' => StubLogger::getInstance(), + ]), StubTransport::getInstance())); + } + + public function testAddClientReport(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::error(), Reason::beforeSend(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertCount(1, StubTransport::$events); + $reports = StubTransport::$events[0]->getClientReports(); + $this->assertCount(2, $reports); + + $report = $reports[0]; + $this->assertSame(DataCategory::profile()->getValue(), $report->getCategory()); + $this->assertSame(Reason::eventProcessor()->getValue(), $report->getReason()); + $this->assertSame(10, $report->getQuantity()); + + $report = $reports[1]; + $this->assertSame(DataCategory::error()->getValue(), $report->getCategory()); + $this->assertSame(Reason::beforeSend()->getValue(), $report->getReason()); + $this->assertSame(10, $report->getQuantity()); + } + + public function testClientReportAggregation(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertCount(1, StubTransport::$events); + $reports = StubTransport::$events[0]->getClientReports(); + $this->assertCount(1, $reports); + + $report = $reports[0]; + $this->assertSame(DataCategory::profile()->getValue(), $report->getCategory()); + $this->assertSame(Reason::eventProcessor()->getValue(), $report->getReason()); + $this->assertSame(40, $report->getQuantity()); + } + + public function testFlushDoesNotOverwriteLastEventId(): void + { + $hub = SentrySdk::getCurrentHub(); + $eventId = $hub->captureMessage('foo'); + + $this->assertNotNull($eventId); + $this->assertSame($eventId, $hub->getLastEventId()); + + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertSame($eventId, $hub->getLastEventId()); + } + + public function testNegativeQuantityDiscarded(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10); + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertNotEmpty(StubLogger::$logs); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => -10]], StubLogger::$logs[0]); + } + + public function testZeroQuantityDiscarded(): void + { + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), 0); + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame(['level' => 'debug', 'message' => 'Dropping Client report with category={category} and reason={reason} because quantity is zero or negative ({quantity})', 'context' => ['category' => 'profile', 'reason' => 'event_processor', 'quantity' => 0]], StubLogger::$logs[0]); + } + + public function testNegativeQuantityDiscardedWhenNoClientIsBound(): void + { + SentrySdk::setCurrentHub(new Hub()); + + ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10); + + SentrySdk::setCurrentHub(new Hub(new Client(new Options([ + 'logger' => StubLogger::getInstance(), + ]), StubTransport::getInstance()))); + + ClientReportAggregator::getInstance()->flush(); + + $this->assertEmpty(StubTransport::$events); + $this->assertEmpty(StubLogger::$logs); + } +} diff --git a/tests/Fixtures/runtime/frankenphp/index.php b/tests/Fixtures/runtime/frankenphp/index.php new file mode 100644 index 0000000000..bbcceb5bb3 --- /dev/null +++ b/tests/Fixtures/runtime/frankenphp/index.php @@ -0,0 +1,84 @@ + false, + 'default_integrations' => false, +]); + +configureScope(static function (Scope $scope): void { + $scope->setTag('baseline', 'yes'); +}); + +$handler = static function (): void { + $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', \PHP_URL_PATH); + + if ($path === '/ping') { + header('Content-Type: text/plain'); + echo 'pong'; + + return; + } + + if ($path !== '/scope') { + http_response_code(404); + header('Content-Type: text/plain'); + echo 'not found'; + + return; + } + + $requestTag = isset($_GET['request']) ? (string) $_GET['request'] : 'none'; + $leakTag = isset($_GET['leak']) ? (string) $_GET['leak'] : null; + + withContext(static function () use ($requestTag, $leakTag): void { + configureScope(static function (Scope $scope) use ($requestTag, $leakTag): void { + $scope->setTag('request', $requestTag); + + if ($leakTag !== null) { + $scope->setTag('leak', $leakTag); + } + }); + + $event = Event::createEvent(); + configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $tags = []; + + if ($event !== null) { + $tags = $event->getTags(); + } + + header('Content-Type: application/json'); + echo json_encode([ + 'runtime_context_id' => SentrySdk::getCurrentRuntimeContext()->getId(), + 'traceparent' => getTraceparent(), + 'tags' => $tags, + ]); + }); +}; + +while (true) { + $keepRunning = frankenphp_handle_request($handler); + gc_collect_cycles(); + + if (!$keepRunning) { + break; + } +} diff --git a/tests/Fixtures/runtime/roadrunner-worker.php b/tests/Fixtures/runtime/roadrunner-worker.php new file mode 100644 index 0000000000..6a4680da83 --- /dev/null +++ b/tests/Fixtures/runtime/roadrunner-worker.php @@ -0,0 +1,138 @@ + false, + 'default_integrations' => false, +]); + +configureScope(static function (Scope $scope): void { + $scope->setTag('baseline', 'yes'); +}); + +$factory = new Psr17Factory(); +$worker = Worker::create(); +$psrWorker = createPsrWorker($worker, $factory); + +while (true) { + try { + $request = $psrWorker->waitRequest(); + } catch (Throwable $exception) { + $worker->error((string) $exception); + + continue; + } + + if ($request === null) { + break; + } + + try { + $response = handleRequest($request); + } catch (Throwable $exception) { + $worker->error((string) $exception); + $response = new Response(500, ['Content-Type' => 'text/plain'], 'internal error'); + } + + try { + $psrWorker->respond($response); + } catch (Throwable $exception) { + $worker->error((string) $exception); + } +} + +function createPsrWorker($worker, $factory) +{ + $reflectionClass = new ReflectionClass(PSR7Worker::class); + $constructor = $reflectionClass->getConstructor(); + $requiredParameterCount = $constructor !== null ? $constructor->getNumberOfRequiredParameters() : 0; + + $arguments = [$worker, $factory, $factory, $factory, $factory]; + + return $reflectionClass->newInstanceArgs(array_slice($arguments, 0, $requiredParameterCount)); +} + +function handleRequest($request): Response +{ + $path = $request->getUri()->getPath(); + + if ($path === '/ping') { + return new Response(200, ['Content-Type' => 'text/plain'], 'pong'); + } + + if ($path !== '/scope') { + return new Response(404, ['Content-Type' => 'text/plain'], 'not found'); + } + + $query = []; + parse_str($request->getUri()->getQuery(), $query); + + $requestTag = isset($query['request']) ? (string) $query['request'] : 'none'; + $leakTag = isset($query['leak']) ? (string) $query['leak'] : null; + + $payload = withContext(static function () use ($requestTag, $leakTag): string { + configureScope(static function (Scope $scope) use ($requestTag, $leakTag): void { + $scope->setTag('request', $requestTag); + + if ($leakTag !== null) { + $scope->setTag('leak', $leakTag); + } + }); + + $event = Event::createEvent(); + configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $tags = []; + + if ($event !== null) { + $tags = $event->getTags(); + } + + $encoded = json_encode([ + 'runtime_context_id' => SentrySdk::getCurrentRuntimeContext()->getId(), + 'traceparent' => getTraceparent(), + 'tags' => $tags, + ]); + + if ($encoded === false) { + return '{}'; + } + + return $encoded; + }); + + return new Response(200, ['Content-Type' => 'application/json'], $payload); +} diff --git a/tests/Fixtures/runtime/roadrunner.rr.yaml b/tests/Fixtures/runtime/roadrunner.rr.yaml new file mode 100644 index 0000000000..8e8f266648 --- /dev/null +++ b/tests/Fixtures/runtime/roadrunner.rr.yaml @@ -0,0 +1,12 @@ +version: "3" + +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: "php roadrunner-worker.php" + +http: + address: 127.0.0.1:8080 + pool: + num_workers: 1 diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index 1b5689b8d3..df1260fa21 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -28,6 +28,8 @@ use Sentry\Tracing\TraceId; use Sentry\Tracing\Transaction; use Sentry\Tracing\TransactionContext; +use Sentry\Transport\Result; +use Sentry\Transport\ResultStatus; use Sentry\Util\SentryUid; use function Sentry\addBreadcrumb; @@ -38,11 +40,14 @@ use function Sentry\captureMessage; use function Sentry\configureScope; use function Sentry\continueTrace; +use function Sentry\endContext; use function Sentry\getBaggage; use function Sentry\getTraceparent; use function Sentry\init; +use function Sentry\startContext; use function Sentry\startTransaction; use function Sentry\trace; +use function Sentry\withContext; use function Sentry\withMonitor; use function Sentry\withScope; @@ -332,6 +337,99 @@ public function testConfigureScope(): void $this->assertTrue($callbackInvoked); } + public function testStartAndEndContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + + startContext(); + + $requestHub = SentrySdk::getCurrentHub(); + + $this->assertNotSame($globalHub, $requestHub); + + endContext(); + + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testWithContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + + $result = withContext(function () use ($globalHub): string { + $this->assertNotSame($globalHub, SentrySdk::getCurrentHub()); + + return 'ok'; + }); + + $this->assertSame('ok', $result); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testNestedWithContextReusesOuterContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $outerHub = null; + $innerHub = null; + + withContext(function () use (&$outerHub, &$innerHub, $globalHub): void { + $outerHub = SentrySdk::getCurrentHub(); + + configureScope(static function (Scope $scope): void { + $scope->setTag('outer', 'yes'); + }); + + withContext(static function () use (&$innerHub): void { + $innerHub = SentrySdk::getCurrentHub(); + }); + + $event = Event::createEvent(); + + configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $this->assertNotSame($globalHub, SentrySdk::getCurrentHub()); + $this->assertSame('yes', $event->getTags()['outer'] ?? null); + }); + + $this->assertNotNull($outerHub); + $this->assertNotNull($innerHub); + $this->assertSame($outerHub, $innerHub); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testWithContextAlwaysEndsContextWithOptionalTimeout(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options()); + $client->expects($this->once()) + ->method('flush') + ->with(13) + ->willReturn(new Result(ResultStatus::success())); + + SentrySdk::init()->bindClient($client); + + try { + withContext(static function (): void { + throw new \RuntimeException('callback failed'); + }, 13); + + $this->fail('The callback exception should be rethrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + } + public function testStartTransaction(): void { $transactionContext = new TransactionContext('foo'); @@ -540,4 +638,77 @@ public function testContinueTrace(): void $this->assertTrue($dynamicSamplingContext->isFrozen()); }); } + + public function testContinueTraceWhenOrgMismatch(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transactionContext = continueTrace( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-org_id=2' + ); + + $newTraceId = (string) $transactionContext->getTraceId(); + $newSampleRand = $transactionContext->getMetadata()->getSampleRand(); + + $this->assertNotSame('566e3688a61d4bc888951642d6f14a19', $newTraceId); + $this->assertNotEmpty($newTraceId); + $this->assertNull($transactionContext->getParentSpanId()); + $this->assertNull($transactionContext->getParentSampled()); + $this->assertNull($transactionContext->getMetadata()->getDynamicSamplingContext()); + $this->assertNotNull($newSampleRand); + + configureScope(function (Scope $scope) use ($newTraceId, $newSampleRand): void { + $propagationContext = $scope->getPropagationContext(); + + $this->assertSame($newTraceId, (string) $propagationContext->getTraceId()); + $this->assertNull($propagationContext->getParentSpanId()); + $this->assertNull($propagationContext->getDynamicSamplingContext()); + $this->assertSame($newSampleRand, $propagationContext->getSampleRand()); + }); + } + + public function testContinueTraceWhenOrgMatch(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $transactionContext = continueTrace( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-org_id=1' + ); + + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $transactionContext->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $transactionContext->getParentSpanId()); + $this->assertTrue($transactionContext->getParentSampled()); + + configureScope(function (Scope $scope): void { + $propagationContext = $scope->getPropagationContext(); + + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $propagationContext->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $propagationContext->getParentSpanId()); + + $dynamicSamplingContext = $propagationContext->getDynamicSamplingContext(); + + $this->assertNotNull($dynamicSamplingContext); + $this->assertSame('1', $dynamicSamplingContext->get('org_id')); + }); + } } diff --git a/tests/HttpClient/HttpClientTest.php b/tests/HttpClient/HttpClientTest.php index 583ba7eff8..45dd466e86 100644 --- a/tests/HttpClient/HttpClientTest.php +++ b/tests/HttpClient/HttpClientTest.php @@ -75,6 +75,59 @@ public function testClientMakesUncompressedRequestWhenCompressionDisabled(): voi $this->assertEquals(\strlen($request->getStringBody()), $serverOutput['headers']['Content-Length']); } + public function testClientMakesRequestWhenShareHandleDisabled(): void + { + $testServer = $this->startTestServer(); + + $options = new Options([ + 'dsn' => "http://publicKey@{$testServer}/200", + 'http_enable_curl_share_handle' => false, + ]); + + $request = new Request(); + $request->setStringBody('test'); + + $client = new HttpClient('sentry.php', 'testing'); + $response = $client->sendRequest($request, $options); + + $serverOutput = $this->stopTestServer(); + + $this->assertTrue($response->isSuccess()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals($response->getStatusCode(), $serverOutput['status']); + $this->assertEquals($request->getStringBody(), $serverOutput['body']); + $this->assertNull($this->getShareHandleFromClient($client)); + } + + public function testShareHandleIsInitializedOnlyOncePerHttpClientInstance(): void + { + $testServer = $this->startTestServer(); + + $options = new Options([ + 'dsn' => "http://publicKey@{$testServer}/200", + 'http_enable_curl_share_handle' => true, + ]); + + $request = new Request(); + $request->setStringBody('test'); + + $client = new HttpClient('sentry.php', 'testing'); + + $firstResponse = $client->sendRequest($request, $options); + $firstShareHandle = $this->getShareHandleFromClient($client); + + $secondResponse = $client->sendRequest($request, $options); + $secondShareHandle = $this->getShareHandleFromClient($client); + + $this->stopTestServer(); + + $this->assertTrue($firstResponse->isSuccess()); + $this->assertTrue($secondResponse->isSuccess()); + $this->assertNotNull($firstShareHandle); + $this->assertShareHandleHasExpectedType($firstShareHandle); + $this->assertSame($firstShareHandle, $secondShareHandle); + } + public function testClientReturnsBodyAsErrorOnNonSuccessStatusCode(): void { $testServer = $this->startTestServer(); @@ -118,4 +171,38 @@ public function testThrowsExceptionIfRequestDataIsEmpty(): void $client = new HttpClient('sentry.php', 'testing'); $client->sendRequest(new Request(), $options); } + + /** + * @return object|resource|null + */ + private function getShareHandleFromClient(HttpClient $client) + { + $reflectionProperty = new \ReflectionProperty(HttpClient::class, 'shareHandle'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + + return $reflectionProperty->getValue($client); + } + + /** + * @param object|resource $shareHandle + */ + private function assertShareHandleHasExpectedType($shareHandle): void + { + if (\PHP_VERSION_ID < 80000) { + $this->assertTrue(\is_resource($shareHandle)); + + return; + } + + if (\PHP_VERSION_ID >= 80500) { + $this->assertTrue(class_exists('CurlSharePersistentHandle')); + $this->assertInstanceOf(\CurlSharePersistentHandle::class, $shareHandle); + + return; + } + + $this->assertInstanceOf(\CurlShareHandle::class, $shareHandle); + } } diff --git a/tests/Integration/FrankenPhpWorkerModeIntegrationTest.php b/tests/Integration/FrankenPhpWorkerModeIntegrationTest.php new file mode 100644 index 0000000000..dfc8947223 --- /dev/null +++ b/tests/Integration/FrankenPhpWorkerModeIntegrationTest.php @@ -0,0 +1,35 @@ +commandIsAvailable('frankenphp version')) { + $this->markTestSkipped('FrankenPHP is not available on PATH.'); + } + } + + protected function startRuntimeServer(): void + { + $serverPort = $this->reserveServerPort(); + $this->setServerPort($serverPort); + + $fixtureRoot = realpath(__DIR__ . '/../Fixtures/runtime/frankenphp'); + + if ($fixtureRoot === false) { + throw new \RuntimeException('Could not resolve FrankenPHP fixture directory.'); + } + + $command = \sprintf( + 'frankenphp php-server --root . --worker index.php --listen 127.0.0.1:%d', + $serverPort + ); + + $this->startServerProcess($command, $fixtureRoot); + $this->waitUntilServerIsReady(); + } +} diff --git a/tests/Integration/RoadRunnerWorkerModeIntegrationTest.php b/tests/Integration/RoadRunnerWorkerModeIntegrationTest.php new file mode 100644 index 0000000000..fac638ebf6 --- /dev/null +++ b/tests/Integration/RoadRunnerWorkerModeIntegrationTest.php @@ -0,0 +1,49 @@ +commandIsAvailable('rr --version')) { + $this->markTestSkipped('RoadRunner binary is not available on PATH.'); + } + + if (!$this->isRoadRunnerPhpWorkerStackAvailable()) { + $this->markTestSkipped('RoadRunner worker classes are missing. Install optional dev deps: spiral/roadrunner-worker, spiral/roadrunner-http, nyholm/psr7.'); + } + } + + protected function startRuntimeServer(): void + { + $httpPort = $this->reserveServerPort(); + $rpcPort = $this->reserveServerPort(); + $this->setServerPort($httpPort); + + $fixtureRoot = realpath(__DIR__ . '/../Fixtures/runtime'); + + if ($fixtureRoot === false) { + throw new \RuntimeException('Could not resolve runtime fixture directory.'); + } + + $command = \sprintf( + 'rr serve -c roadrunner.rr.yaml -o http.address=127.0.0.1:%d -o rpc.listen=tcp://127.0.0.1:%d', + $httpPort, + $rpcPort + ); + + $this->startServerProcess($command, $fixtureRoot); + $this->waitUntilServerIsReady(); + } + + private function isRoadRunnerPhpWorkerStackAvailable(): bool + { + return class_exists(\Spiral\RoadRunner\Worker::class) + && class_exists(\Spiral\RoadRunner\Http\PSR7Worker::class) + && class_exists(\Nyholm\Psr7\Factory\Psr17Factory::class) + && class_exists(\Nyholm\Psr7\Response::class); + } +} diff --git a/tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php b/tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php new file mode 100644 index 0000000000..1fbbaafca7 --- /dev/null +++ b/tests/Integration/RuntimeContextWorkerModeIntegrationTestCase.php @@ -0,0 +1,256 @@ +stopServerProcess(); + + parent::tearDown(); + } + + final public function testWithContextPreventsScopeBleedingAcrossWorkerRequests(): void + { + $this->skipUnlessRuntimeIsAvailable(); + $this->startRuntimeServer(); + + try { + $firstResponse = $this->requestJson('/scope?request=first&leak=first-only'); + $secondResponse = $this->requestJson('/scope?request=second'); + } finally { + $this->stopServerProcess(); + } + + $this->assertSame('yes', $firstResponse['tags']['baseline'] ?? null); + $this->assertSame('yes', $secondResponse['tags']['baseline'] ?? null); + + $this->assertSame('first', $firstResponse['tags']['request'] ?? null); + $this->assertSame('second', $secondResponse['tags']['request'] ?? null); + + $this->assertSame('first-only', $firstResponse['tags']['leak'] ?? null); + $this->assertArrayNotHasKey('leak', $secondResponse['tags']); + + $this->assertNotSame($firstResponse['runtime_context_id'], $secondResponse['runtime_context_id']); + $this->assertNotSame($firstResponse['traceparent'] ?? null, $secondResponse['traceparent'] ?? null); + } + + abstract protected function skipUnlessRuntimeIsAvailable(): void; + + abstract protected function startRuntimeServer(): void; + + final protected function reserveServerPort(): int + { + $server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errorMessage); + + if ($server === false) { + throw new \RuntimeException(\sprintf('Failed allocating a test port: %s', $errorMessage)); + } + + $address = stream_socket_get_name($server, false); + fclose($server); + + if (!\is_string($address)) { + throw new \RuntimeException('Could not determine allocated test port.'); + } + + $parts = explode(':', $address); + $port = (int) array_pop($parts); + + if ($port <= 0) { + throw new \RuntimeException(\sprintf('Invalid allocated test port from address "%s".', $address)); + } + + return $port; + } + + final protected function setServerPort(int $serverPort): void + { + $this->serverPort = $serverPort; + } + + final protected function startServerProcess(string $command, string $workingDirectory): void + { + if ($this->serverProcess !== null) { + throw new \RuntimeException('Server process is already running.'); + } + + $pipes = []; + $this->serverProcess = proc_open( + $command, + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $workingDirectory + ); + + if (!\is_resource($this->serverProcess)) { + throw new \RuntimeException(\sprintf('Unable to start server process with command: %s', $command)); + } + + $this->serverStdout = $pipes[1]; + $this->serverStderr = $pipes[2]; + + stream_set_blocking($this->serverStdout, false); + stream_set_blocking($this->serverStderr, false); + } + + final protected function waitUntilServerIsReady(string $path = '/ping', int $attempts = 200, int $sleepMicros = 50000): void + { + $context = stream_context_create(['http' => ['timeout' => 1]]); + $url = \sprintf('http://127.0.0.1:%d%s', $this->getServerPort(), $path); + + for ($i = 0; $i < $attempts; ++$i) { + $response = @file_get_contents($url, false, $context); + + if ($response === 'pong') { + return; + } + + if ($this->serverProcess === null) { + throw new \RuntimeException('Server process is not running.'); + } + + $status = proc_get_status($this->serverProcess); + + if (!$status['running']) { + throw new \RuntimeException('Server process exited before becoming ready: ' . $this->collectServerOutput()); + } + + usleep($sleepMicros); + } + + throw new \RuntimeException('Timed out waiting for server readiness: ' . $this->collectServerOutput()); + } + + /** + * @return array{runtime_context_id: string, traceparent: string, tags: array} + */ + final protected function requestJson(string $path): array + { + $url = \sprintf('http://127.0.0.1:%d%s', $this->getServerPort(), $path); + $context = stream_context_create(['http' => ['timeout' => 2, 'ignore_errors' => true]]); + $body = @file_get_contents($url, false, $context); + $responseHeaders = $http_response_header ?? []; + + if ($body === false) { + throw new \RuntimeException(\sprintf('Failed HTTP request to %s.', $url)); + } + + $statusLine = $responseHeaders[0] ?? ''; + + if (strpos($statusLine, '200') === false) { + throw new \RuntimeException(\sprintf('Unexpected HTTP status for %s: %s Body: %s', $url, $statusLine, $body)); + } + + $decoded = json_decode($body, true); + + if (!\is_array($decoded)) { + throw new \RuntimeException(\sprintf('Response body was not valid JSON for %s: %s', $url, $body)); + } + + return $decoded; + } + + final protected function stopServerProcess(): void + { + if ($this->serverProcess === null) { + return; + } + + $status = proc_get_status($this->serverProcess); + + if ($status['running']) { + $this->killProcessTree((int) $status['pid']); + } + + proc_close($this->serverProcess); + + if (\is_resource($this->serverStdout)) { + fclose($this->serverStdout); + } + + if (\is_resource($this->serverStderr)) { + fclose($this->serverStderr); + } + + $this->serverProcess = null; + $this->serverStdout = null; + $this->serverStderr = null; + $this->serverPort = null; + } + + final protected function commandIsAvailable(string $command): bool + { + $output = []; + $exitCode = 1; + + exec($command . ' 2>&1', $output, $exitCode); + + return $exitCode === 0; + } + + private function getServerPort(): int + { + if ($this->serverPort === null) { + throw new \RuntimeException('Server port has not been set.'); + } + + return $this->serverPort; + } + + private function collectServerOutput(): string + { + $stdout = ''; + $stderr = ''; + + if (\is_resource($this->serverStdout)) { + $stdout = stream_get_contents($this->serverStdout); + } + + if (\is_resource($this->serverStderr)) { + $stderr = stream_get_contents($this->serverStderr); + } + + return trim($stdout . "\n" . $stderr); + } + + private function killProcessTree(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec(\sprintf('taskkill /pid %d /f /t', $pid)); + } else { + exec(\sprintf('pkill -P %d', $pid)); + exec(\sprintf('kill %d', $pid)); + } + + proc_terminate($this->serverProcess, 9); + } +} diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 72f497002c..972eab9cd8 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -14,7 +14,7 @@ use Sentry\Options; use Sentry\State\HubAdapter; -use function Sentry\trace_metrics; +use function Sentry\traceMetrics; final class TraceMetricsTest extends TestCase { @@ -26,9 +26,9 @@ protected function setUp(): void public function testCounterMetrics(): void { - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -43,8 +43,8 @@ public function testCounterMetrics(): void public function testGaugeMetrics(): void { - trace_metrics()->gauge('test-gauge', 10, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->gauge('test-gauge', 10, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -59,8 +59,8 @@ public function testGaugeMetrics(): void public function testDistributionMetrics(): void { - trace_metrics()->distribution('test-distribution', 10, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->distribution('test-distribution', 10, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $this->assertCount(1, $event->getMetrics()); @@ -75,9 +75,9 @@ public function testDistributionMetrics(): void public function testMetricsBufferFull(): void { for ($i = 0; $i < MetricsAggregator::METRICS_BUFFER_SIZE + 100; ++$i) { - trace_metrics()->count('test', 1, ['foo' => 'bar']); + traceMetrics()->count('test', 1, ['foo' => 'bar']); } - trace_metrics()->flush(); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $metrics = $event->getMetrics(); @@ -90,8 +90,8 @@ public function testEnableMetrics(): void 'enable_metrics' => false, ]), StubTransport::getInstance())); - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertEmpty(StubTransport::$events); } @@ -106,8 +106,8 @@ public function testBeforeSendMetricAltersContent() }, ]), StubTransport::getInstance())); - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -119,8 +119,8 @@ public function testBeforeSendMetricAltersContent() public function testIntType() { - trace_metrics()->count('test-count', 2, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -134,8 +134,8 @@ public function testIntType() public function testFloatType(): void { - trace_metrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); - trace_metrics()->flush(); + traceMetrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -150,8 +150,8 @@ public function testFloatType(): void public function testInvalidTypeIsDiscarded(): void { // @phpstan-ignore-next-line - trace_metrics()->count('test-count', 'test-value'); - trace_metrics()->flush(); + traceMetrics()->count('test-count', 'test-value'); + traceMetrics()->flush(); $this->assertEmpty(StubTransport::$events); } diff --git a/tests/Monolog/BreadcrumbHandlerTest.php b/tests/Monolog/BreadcrumbHandlerTest.php index ffef9e1925..812c5d12fd 100644 --- a/tests/Monolog/BreadcrumbHandlerTest.php +++ b/tests/Monolog/BreadcrumbHandlerTest.php @@ -15,17 +15,19 @@ final class BreadcrumbHandlerTest extends TestCase { /** * @dataProvider handleDataProvider + * + * @param LogRecord|array $record */ public function testHandle($record, Breadcrumb $expectedBreadcrumb): void { $hub = $this->createMock(HubInterface::class); $hub->expects($this->once()) ->method('addBreadcrumb') - ->with($this->callback(function (Breadcrumb $breadcrumb) use ($expectedBreadcrumb, $record): bool { + ->with($this->callback(function (Breadcrumb $breadcrumb) use ($expectedBreadcrumb): bool { $this->assertSame($expectedBreadcrumb->getMessage(), $breadcrumb->getMessage()); $this->assertSame($expectedBreadcrumb->getLevel(), $breadcrumb->getLevel()); $this->assertSame($expectedBreadcrumb->getType(), $breadcrumb->getType()); - $this->assertEquals($record['datetime']->getTimestamp(), $breadcrumb->getTimestamp()); + $this->assertEquals($expectedBreadcrumb->getTimestamp(), $breadcrumb->getTimestamp()); $this->assertSame($expectedBreadcrumb->getCategory(), $breadcrumb->getCategory()); $this->assertEquals($expectedBreadcrumb->getMetadata(), $breadcrumb->getMetadata()); @@ -37,16 +39,19 @@ public function testHandle($record, Breadcrumb $expectedBreadcrumb): void } /** - * @return iterable, Breadcrumb}> + * @return iterable, Breadcrumb}> */ public static function handleDataProvider(): iterable { + $now = new \DateTimeImmutable(); + $defaultBreadcrumb = new Breadcrumb( Breadcrumb::LEVEL_DEBUG, Breadcrumb::TYPE_DEFAULT, 'channel.foo', 'foo bar', - [] + [], + (float) $now->format('U.u') ); $levelsToBeTested = [ @@ -58,31 +63,46 @@ public static function handleDataProvider(): iterable foreach ($levelsToBeTested as $loggerLevel => $breadcrumbLevel) { yield 'with level ' . Logger::getLevelName($loggerLevel) => [ - RecordFactory::create('foo bar', $loggerLevel, 'channel.foo', [], []), + RecordFactory::create('foo bar', $loggerLevel, 'channel.foo', [], [], $now), $defaultBreadcrumb->withLevel($breadcrumbLevel), ]; } yield 'with level ERROR' => [ - RecordFactory::create('foo bar', Logger::ERROR, 'channel.foo', [], []), + RecordFactory::create('foo bar', Logger::ERROR, 'channel.foo', [], [], $now), $defaultBreadcrumb->withLevel(Breadcrumb::LEVEL_ERROR) ->withType(Breadcrumb::TYPE_ERROR), ]; yield 'with level ALERT' => [ - RecordFactory::create('foo bar', Logger::ALERT, 'channel.foo', [], []), + RecordFactory::create('foo bar', Logger::ALERT, 'channel.foo', [], [], $now), $defaultBreadcrumb->withLevel(Breadcrumb::LEVEL_FATAL) ->withType(Breadcrumb::TYPE_ERROR), ]; yield 'with context' => [ - RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', ['context' => ['foo' => 'bar']], []), + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', ['context' => ['foo' => 'bar']], [], $now), $defaultBreadcrumb->withMetadata('context', ['foo' => 'bar']), ]; yield 'with extra' => [ - RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], ['extra' => ['foo' => 'bar']]), + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], ['extra' => ['foo' => 'bar']], $now), $defaultBreadcrumb->withMetadata('extra', ['foo' => 'bar']), ]; + + yield 'with timestamp' => [ + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], [], new \DateTimeImmutable('1970-01-01 00:00:42.1337 UTC')), + $defaultBreadcrumb->withTimestamp(42.1337), + ]; + + yield 'with zero timestamp' => [ + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], [], new \DateTimeImmutable('1970-01-01 00:00:00.000 UTC')), + $defaultBreadcrumb->withTimestamp(0.0), + ]; + + yield 'with negative timestamp' => [ + RecordFactory::create('foo bar', Logger::DEBUG, 'channel.foo', [], [], new \DateTimeImmutable('1969-12-31 23:59:56.859 UTC')), + $defaultBreadcrumb->withTimestamp(-3.141), + ]; } } diff --git a/tests/Monolog/RecordFactory.php b/tests/Monolog/RecordFactory.php index be22130590..889bb1f548 100644 --- a/tests/Monolog/RecordFactory.php +++ b/tests/Monolog/RecordFactory.php @@ -19,11 +19,15 @@ final class RecordFactory * * @return array|LogRecord */ - public static function create(string $message, int $level, string $channel, array $context = [], array $extra = []) + public static function create(string $message, int $level, string $channel, array $context = [], array $extra = [], ?\DateTimeImmutable $datetime = null) { + if ($datetime === null) { + $datetime = new \DateTimeImmutable(); + } + if (Logger::API >= 3) { return new LogRecord( - new \DateTimeImmutable(), + $datetime, $channel, Logger::toMonologLevel($level), $message, @@ -39,7 +43,7 @@ public static function create(string $message, int $level, string $channel, arra 'level_name' => Logger::getLevelName($level), 'channel' => $channel, 'extra' => $extra, - 'datetime' => new \DateTimeImmutable(), + 'datetime' => $datetime, ]; } } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 0db7abcd2a..d2e6043994 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -261,6 +261,13 @@ static function (): void {}, 'setTracePropagationTargets', ]; + yield [ + 'strict_trace_continuation', + true, + 'isStrictTraceContinuationEnabled', + 'enableStrictTraceContinuation', + ]; + yield [ 'strict_trace_propagation', true, @@ -380,6 +387,13 @@ static function (): void {}, 'setEnableHttpCompression', ]; + yield [ + 'http_enable_curl_share_handle', + false, + 'isShareHandleEnabled', + 'setEnableShareHandle', + ]; + yield [ 'capture_silenced_errors', true, diff --git a/tests/SentrySdkExtension.php b/tests/SentrySdkExtension.php index 8637499eec..82ce507612 100644 --- a/tests/SentrySdkExtension.php +++ b/tests/SentrySdkExtension.php @@ -22,6 +22,15 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + $reflectionProperty = new \ReflectionProperty(SentrySdk::class, 'runtimeContextManager'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + $reflectionProperty->setValue(null, null); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(false); + } + $reflectionProperty = new \ReflectionProperty(Scope::class, 'globalEventProcessors'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); diff --git a/tests/SentrySdkTest.php b/tests/SentrySdkTest.php index f85fbc9b8a..633881bdf0 100644 --- a/tests/SentrySdkTest.php +++ b/tests/SentrySdkTest.php @@ -4,10 +4,19 @@ namespace Sentry\Tests; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sentry\NoOpClient; +use Sentry\ClientInterface; +use Sentry\Event; +use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; +use Sentry\State\Scope; +use Sentry\Tracing\Span; +use Sentry\Tracing\SpanContext; +use Sentry\Transport\Result; +use Sentry\Transport\ResultStatus; final class SentrySdkTest extends TestCase { @@ -37,4 +46,234 @@ public function testSetCurrentHub(): void $this->assertSame($hub, SentrySdk::setCurrentHub($hub)); $this->assertSame($hub, SentrySdk::getCurrentHub()); } + + public function testStartAndEndContextIsolateScopeData(): void + { + SentrySdk::init(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { + $scope->setTag('baseline', 'yes'); + }); + + SentrySdk::startContext(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { + $scope->setTag('request', 'yes'); + }); + + SentrySdk::endContext(); + + $event = Event::createEvent(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $this->assertArrayHasKey('baseline', $event->getTags()); + $this->assertArrayNotHasKey('request', $event->getTags()); + } + + public function testStartContextDoesNotInheritBaselineSpan(): void + { + SentrySdk::init(); + + $baselineSpan = new Span(new SpanContext()); + SentrySdk::getCurrentHub()->setSpan($baselineSpan); + + SentrySdk::startContext(); + $contextHub = SentrySdk::getCurrentHub(); + + $this->assertNull($contextHub->getSpan()); + + SentrySdk::endContext(); + + $this->assertSame($baselineSpan, SentrySdk::getCurrentHub()->getSpan()); + } + + public function testStartContextCreatesFreshPropagationContext(): void + { + SentrySdk::init(); + + $globalTraceparent = $this->getCurrentScopeTraceparent(); + + SentrySdk::startContext(); + $firstContextTraceparent = $this->getCurrentScopeTraceparent(); + SentrySdk::endContext(); + + SentrySdk::startContext(); + $secondContextTraceparent = $this->getCurrentScopeTraceparent(); + SentrySdk::endContext(); + + $this->assertNotSame($globalTraceparent, $firstContextTraceparent); + $this->assertNotSame($firstContextTraceparent, $secondContextTraceparent); + } + + public function testWithContextResetsSpanAndTransactionAcrossInvocations(): void + { + SentrySdk::init(); + + SentrySdk::withContext(function (): void { + $transaction = SentrySdk::getCurrentHub()->startTransaction(new \Sentry\Tracing\TransactionContext('request-1')); + SentrySdk::getCurrentHub()->setSpan($transaction); + + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getSpan()); + $this->assertSame($transaction, SentrySdk::getCurrentHub()->getTransaction()); + }); + + SentrySdk::withContext(function (): void { + $this->assertNull(SentrySdk::getCurrentHub()->getSpan()); + $this->assertNull(SentrySdk::getCurrentHub()->getTransaction()); + }); + } + + public function testNestedStartContextIsNoOp(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + + SentrySdk::startContext(); + $firstContextHub = SentrySdk::getCurrentHub(); + + SentrySdk::startContext(); + $secondContextHub = SentrySdk::getCurrentHub(); + + $this->assertNotSame($globalHub, $firstContextHub); + $this->assertSame($firstContextHub, $secondContextHub); + + SentrySdk::endContext(); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + + SentrySdk::endContext(); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testEndContextFlushesClientTransportWithOptionalTimeout(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options()); + $client->expects($this->once()) + ->method('flush') + ->with(12) + ->willReturn(new Result(ResultStatus::success())); + + SentrySdk::init()->bindClient($client); + + SentrySdk::startContext(); + SentrySdk::endContext(12); + } + + public function testFlushFlushesClientTransport(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('flush') + ->with(null) + ->willReturn(new Result(ResultStatus::success())); + + SentrySdk::init()->bindClient($client); + + SentrySdk::flush(); + } + + public function testWithContextReturnsCallbackResultAndRestoresGlobalHub(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $callbackHub = null; + + $result = SentrySdk::withContext(static function () use (&$callbackHub): string { + $callbackHub = SentrySdk::getCurrentHub(); + + return 'ok'; + }); + + $this->assertSame('ok', $result); + $this->assertNotNull($callbackHub); + $this->assertNotSame($globalHub, $callbackHub); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testNestedWithContextReusesOuterContext(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $outerHub = null; + $innerHub = null; + $outerContextId = null; + $innerContextId = null; + + SentrySdk::withContext(function () use (&$outerHub, &$innerHub, &$outerContextId, &$innerContextId, $globalHub): void { + $outerHub = SentrySdk::getCurrentHub(); + $outerContextId = SentrySdk::getCurrentRuntimeContext()->getId(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope): void { + $scope->setTag('outer', 'yes'); + }); + + SentrySdk::withContext(static function () use (&$innerHub, &$innerContextId): void { + $innerHub = SentrySdk::getCurrentHub(); + $innerContextId = SentrySdk::getCurrentRuntimeContext()->getId(); + }); + + $event = Event::createEvent(); + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use (&$event): void { + $event = $scope->applyToEvent($event); + }); + + $this->assertNotSame($globalHub, SentrySdk::getCurrentHub()); + $this->assertSame('yes', $event->getTags()['outer'] ?? null); + $this->assertSame($outerContextId, SentrySdk::getCurrentRuntimeContext()->getId()); + }); + + $this->assertNotNull($outerHub); + $this->assertNotNull($innerHub); + $this->assertNotNull($outerContextId); + $this->assertNotNull($innerContextId); + $this->assertSame($outerHub, $innerHub); + $this->assertSame($outerContextId, $innerContextId); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + public function testWithContextEndsContextWhenCallbackThrows(): void + { + SentrySdk::init(); + + $globalHub = SentrySdk::getCurrentHub(); + $callbackHub = null; + + try { + SentrySdk::withContext(static function () use (&$callbackHub): void { + $callbackHub = SentrySdk::getCurrentHub(); + + throw new \RuntimeException('boom'); + }); + + $this->fail('The callback exception should be rethrown.'); + } catch (\RuntimeException $exception) { + $this->assertSame('boom', $exception->getMessage()); + } + + $this->assertNotNull($callbackHub); + $this->assertNotSame($globalHub, $callbackHub); + $this->assertSame($globalHub, SentrySdk::getCurrentHub()); + } + + private function getCurrentScopeTraceparent(): string + { + $traceparent = ''; + + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) use (&$traceparent): void { + $traceparent = $scope->getPropagationContext()->toTraceparent(); + }); + + return $traceparent; + } } diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 25e5f5791e..50b1c02f20 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -10,6 +10,7 @@ use Sentry\CheckIn; use Sentry\CheckInStatus; use Sentry\Client; +use Sentry\ClientReport\DiscardedEvent; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Event; @@ -473,6 +474,50 @@ public static function serializeAsEnvelopeDataProvider(): iterable TEXT ]; + $event = Event::createClientReport(); + + yield [ + $event, + <<setClientReports([ + new DiscardedEvent('log_item', 'buffer_overflow', 1), + new DiscardedEvent('log_byte', 'buffer_overflow', 256), + ]); + + yield [ + $event, + <<setClientReports([ + new DiscardedEvent('error', 'before_send', 10), + new DiscardedEvent('profile', 'internal_sdk_error', 50), + ]); + + yield [ + $event, + <<setAttachments([ diff --git a/tests/State/HubTest.php b/tests/State/HubTest.php index 54c5a9dfa1..364dc95f1b 100644 --- a/tests/State/HubTest.php +++ b/tests/State/HubTest.php @@ -637,6 +637,22 @@ public function testStartTransactionWithTracesSampler(Options $options, Transact $this->assertSame($expectedSampled, $transaction->getSampled()); } + public function testStartTransactionIgnoresBaggageSampleRateWithoutSentryTrace(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 0.0, + ])); + + $hub = new Hub($client); + $transactionContext = TransactionContext::fromHeaders('', 'sentry-sample_rate=1'); + $transaction = $hub->startTransaction($transactionContext); + + $this->assertFalse($transaction->getSampled()); + } + public static function startTransactionDataProvider(): iterable { yield 'Acceptable float value returned from traces_sampler' => [ diff --git a/tests/StubLogger.php b/tests/StubLogger.php new file mode 100644 index 0000000000..571cf2808f --- /dev/null +++ b/tests/StubLogger.php @@ -0,0 +1,38 @@ + $level, + 'message' => $message, + 'context' => $context, + ]; + } +} diff --git a/tests/StubTransport.php b/tests/StubTransport.php index 8a427622df..f80d69045a 100644 --- a/tests/StubTransport.php +++ b/tests/StubTransport.php @@ -37,7 +37,7 @@ public function send(Event $event): Result { self::$events[] = $event; - return new Result(ResultStatus::success()); + return new Result(ResultStatus::success(), $event); } public function close(?int $timeout = null): Result diff --git a/tests/Tracing/DynamicSamplingContextTest.php b/tests/Tracing/DynamicSamplingContextTest.php index 574aaf4da1..0996aa7021 100644 --- a/tests/Tracing/DynamicSamplingContextTest.php +++ b/tests/Tracing/DynamicSamplingContextTest.php @@ -154,6 +154,64 @@ public function testFromOptions(): void $this->assertTrue($samplingContext->isFrozen()); } + public function testFromOptionsUsesConfiguredOrgIdOverDsnOrgId(): void + { + $options = new Options([ + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ]); + + $scope = new Scope(); + $samplingContext = DynamicSamplingContext::fromOptions($options, $scope); + + $this->assertSame('2', $samplingContext->get('org_id')); + } + + public function testFromOptionsFallsBackToDsnOrgId(): void + { + $options = new Options([ + 'dsn' => 'http://public@o1.example.com/1', + ]); + + $scope = new Scope(); + $samplingContext = DynamicSamplingContext::fromOptions($options, $scope); + + $this->assertSame('1', $samplingContext->get('org_id')); + } + + public function testFromTransactionUsesConfiguredOrgIdOverDsnOrgId(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ])); + + $hub = new Hub($client); + $transaction = new Transaction(new TransactionContext(), $hub); + $samplingContext = DynamicSamplingContext::fromTransaction($transaction, $hub); + + $this->assertSame('2', $samplingContext->get('org_id')); + } + + public function testFromTransactionFallsBackToDsnOrgId(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'http://public@o1.example.com/1', + ])); + + $hub = new Hub($client); + $transaction = new Transaction(new TransactionContext(), $hub); + $samplingContext = DynamicSamplingContext::fromTransaction($transaction, $hub); + + $this->assertSame('1', $samplingContext->get('org_id')); + } + /** * @dataProvider getEntriesDataProvider */ diff --git a/tests/Tracing/StrictTraceContinuationTest.php b/tests/Tracing/StrictTraceContinuationTest.php new file mode 100644 index 0000000000..a92a1eab7f --- /dev/null +++ b/tests/Tracing/StrictTraceContinuationTest.php @@ -0,0 +1,214 @@ +createMock(ClientInterface::class); + $client->expects($this->exactly(2)) + ->method('getOptions') + ->willReturn($options); + + SentrySdk::setCurrentHub(new Hub($client)); + + $contexts = [ + PropagationContext::fromHeaders(self::SENTRY_TRACE_HEADER, $baggage), + PropagationContext::fromEnvironment(self::SENTRY_TRACE_HEADER, $baggage), + ]; + + foreach ($contexts as $context) { + if ($expectedContinueTrace) { + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $context->getParentSpanId()); + } else { + $this->assertNotSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertNotEmpty((string) $context->getTraceId()); + $this->assertNull($context->getParentSpanId()); + $this->assertNull($context->getDynamicSamplingContext()); + } + } + } + + /** + * @dataProvider strictTraceContinuationDataProvider + */ + public function testTransactionContext(Options $options, string $baggage, bool $expectedContinueTrace): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->exactly(2)) + ->method('getOptions') + ->willReturn($options); + + SentrySdk::setCurrentHub(new Hub($client)); + + $contexts = [ + TransactionContext::fromHeaders(self::SENTRY_TRACE_HEADER, $baggage), + TransactionContext::fromEnvironment(self::SENTRY_TRACE_HEADER, $baggage), + ]; + + foreach ($contexts as $context) { + if ($expectedContinueTrace) { + $this->assertSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertSame('566e3688a61d4bc8', (string) $context->getParentSpanId()); + $this->assertTrue($context->getParentSampled()); + } else { + $this->assertNotSame('566e3688a61d4bc888951642d6f14a19', (string) $context->getTraceId()); + $this->assertNull($context->getParentSpanId()); + $this->assertNull($context->getParentSampled()); + $this->assertNull($context->getMetadata()->getDynamicSamplingContext()); + } + } + } + + public static function strictTraceContinuationDataProvider(): \Generator + { + // First 10 Test cases are modelled after: https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 1, + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 1, + ]), + '', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + ]), + '', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 2, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 1, + ]), + '', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + ]), + '', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'org_id' => 2, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'dsn' => 'http://public@o1.example.com/1', + ]), + 'sentry-org_id=1', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ]), + 'sentry-org_id=1', + false, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => true, + 'dsn' => 'http://public@o1.example.com/1', + 'org_id' => 2, + ]), + 'sentry-org_id=2', + true, + ]; + + yield [ + new Options([ + 'strict_trace_continuation' => false, + 'org_id' => 1, + ]), + 'sentry-org_id=01', + false, + ]; + } +} diff --git a/tests/Tracing/TransactionContextTest.php b/tests/Tracing/TransactionContextTest.php index 93dd5bfc5f..3f382d39fd 100644 --- a/tests/Tracing/TransactionContextTest.php +++ b/tests/Tracing/TransactionContextTest.php @@ -149,4 +149,11 @@ public function testSampleRandRangeWhenParentNotSampledAndSampleRateProvided(): $this->assertGreaterThanOrEqual(0.4, $sampleRand); $this->assertLessThanOrEqual(0.999999, $sampleRand); } + + public function testParentSamplingRateIsIgnoredWithoutSentryTraceHeader(): void + { + $context = TransactionContext::fromHeaders('', 'sentry-sample_rate=1'); + + $this->assertNull($context->getMetadata()->getParentSamplingRate()); + } } diff --git a/tests/Transport/HttpTransportTest.php b/tests/Transport/HttpTransportTest.php index 206261eb0c..7eb93cd3ff 100644 --- a/tests/Transport/HttpTransportTest.php +++ b/tests/Transport/HttpTransportTest.php @@ -114,6 +114,18 @@ public static function sendDataProvider(): iterable ], ]; + yield [ + new Response(413, [], ''), + ResultStatus::contentTooLarge(), + false, + [ + 'info' => [ + 'Sending event [%s] to %s [project:%s].', + 'Sent event [%s] to %s [project:%s]. Result: "content_too_large" (status: 413).', + ], + ], + ]; + ClockMock::withClockMock(1644105600); yield [ diff --git a/tests/UserDataBagTest.php b/tests/UserDataBagTest.php index 065706deb1..a7a20ad501 100644 --- a/tests/UserDataBagTest.php +++ b/tests/UserDataBagTest.php @@ -143,29 +143,46 @@ public static function unexpectedValueForIdFieldDataProvider(): iterable ]; } - public function testConstructorThrowsIfIpAddressArgumentIsInvalid(): void + /** + * @dataProvider bracketedIpv6AddressDataProvider + */ + public function testSetIpAddressStripsBracketsFromIpv6(string $input, string $expected): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The "foo" value is not a valid IP address.'); + $userDataBag = new UserDataBag(); + $userDataBag->setIpAddress($input); - new UserDataBag(null, null, 'foo'); + $this->assertSame($expected, $userDataBag->getIpAddress()); + } + + public static function bracketedIpv6AddressDataProvider(): iterable + { + yield 'IPv6 loopback with brackets' => ['[::1]', '::1']; + yield 'IPv6 full address with brackets' => ['[2001:db8::1]', '2001:db8::1']; + yield 'IPv6 loopback without brackets' => ['::1', '::1']; + yield 'IPv4 address' => ['127.0.0.1', '127.0.0.1']; } - public function testSetIpAddressThrowsIfArgumentIsInvalid(): void + public function testConstructorDoesNotSetInvalidIpAddress(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The "foo" value is not a valid IP address.'); + $userDataBag = new UserDataBag(null, null, 'foo'); + $this->assertNull($userDataBag->getIpAddress()); + } + + public function testSetIpAddressDoesNotSetInvalidIpAddress(): void + { $userDataBag = new UserDataBag(); + $userDataBag->setIpAddress('127.0.0.1'); $userDataBag->setIpAddress('foo'); + + $this->assertSame('127.0.0.1', $userDataBag->getIpAddress()); } - public function testCreateFromIpAddressThrowsIfArgumentIsInvalid(): void + public function testCreateFromIpAddressDoesNotSetInvalidIpAddress(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The "foo" value is not a valid IP address.'); + $userDataBag = UserDataBag::createFromUserIpAddress('foo'); - UserDataBag::createFromUserIpAddress('foo'); + $this->assertNull($userDataBag->getIpAddress()); } public function testMerge(): void diff --git a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt index 321ed418a1..35a32c8381 100644 --- a/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt +++ b/tests/phpt-oom/php84/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -32,6 +32,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $options = new Options([ 'dsn' => 'http://public@example.com/sentry/1', ]); diff --git a/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt b/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt index 92e0305881..a547854dab 100644 --- a/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt +++ b/tests/phpt-oom/php85/out_of_memory_fatal_error_increases_memory_limit.phpt @@ -24,6 +24,8 @@ use Sentry\Transport\Result; use Sentry\Transport\ResultStatus; use Sentry\Transport\TransportInterface; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $vendor = __DIR__; while (!file_exists($vendor . '/vendor')) { diff --git a/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt b/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt index f52c12932e..fe1cf65e24 100644 --- a/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt +++ b/tests/phpt/error_handler_captures_errors_not_silencable_on_php_8_and_up.phpt @@ -45,7 +45,7 @@ $transport = new class implements TransportInterface { } }; -error_reporting(E_ALL & ~E_USER_ERROR); +error_reporting(E_ALL & ~E_USER_ERROR & ~E_DEPRECATED & ~E_USER_DEPRECATED); $options = [ 'dsn' => 'http://public@example.com/sentry/1', diff --git a/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt b/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt index 0063489b73..11ffa7c3c8 100644 --- a/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt +++ b/tests/phpt/error_handler_respects_capture_silenced_errors_option.phpt @@ -25,6 +25,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt b/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt index 35deedee90..592ebaa37d 100644 --- a/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt +++ b/tests/phpt/error_handler_respects_error_types_option_regardless_of_error_reporting.phpt @@ -39,7 +39,7 @@ $transport = new class implements TransportInterface { } }; -error_reporting(E_ALL & ~E_USER_NOTICE & ~E_USER_WARNING & ~E_USER_ERROR); +error_reporting(E_ALL & ~E_USER_NOTICE & ~E_USER_WARNING & ~E_USER_ERROR & ~E_DEPRECATED & ~E_USER_DEPRECATED); $options = [ 'dsn' => 'http://public@example.com/sentry/1', diff --git a/tests/phpt/error_listener_integration_respects_error_types_option.phpt b/tests/phpt/error_listener_integration_respects_error_types_option.phpt index 4c66a5f9aa..20d413ecde 100644 --- a/tests/phpt/error_listener_integration_respects_error_types_option.phpt +++ b/tests/phpt/error_listener_integration_respects_error_types_option.phpt @@ -28,6 +28,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php84/error_handler_captures_fatal_error.phpt b/tests/phpt/php84/error_handler_captures_fatal_error.phpt index 2c4b9143bd..9225fbafe1 100644 --- a/tests/phpt/php84/error_handler_captures_fatal_error.phpt +++ b/tests/phpt/php84/error_handler_captures_fatal_error.phpt @@ -29,6 +29,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt b/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt index 2c8e40c4d7..7b76f6fedb 100644 --- a/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt +++ b/tests/phpt/php84/fatal_error_integration_captures_fatal_error.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt b/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt index 6dc65c4349..56620b1c03 100644 --- a/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt +++ b/tests/phpt/php84/fatal_error_integration_respects_error_types_option.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php85/error_handler_captures_fatal_error.phpt b/tests/phpt/php85/error_handler_captures_fatal_error.phpt index 4a4fe0e98d..03f77c5891 100644 --- a/tests/phpt/php85/error_handler_captures_fatal_error.phpt +++ b/tests/phpt/php85/error_handler_captures_fatal_error.phpt @@ -29,6 +29,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt b/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt index 88fdd8f5cc..b6c62b636c 100644 --- a/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt +++ b/tests/phpt/php85/fatal_error_integration_captures_fatal_error.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt b/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt index 7d74233ce5..dbf4e8356f 100644 --- a/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt +++ b/tests/phpt/php85/fatal_error_integration_respects_error_types_option.phpt @@ -31,6 +31,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { diff --git a/tests/phpt/serialize_broken_class.phpt b/tests/phpt/serialize_broken_class.phpt index 0df9ab8c17..14021bc8a7 100644 --- a/tests/phpt/serialize_broken_class.phpt +++ b/tests/phpt/serialize_broken_class.phpt @@ -19,6 +19,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + // issue present itself in backtrace serialization, see: // - https://github.com/getsentry/sentry-php/pull/818 // - https://github.com/getsentry/sentry-symfony/issues/63#issuecomment-493046411 diff --git a/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt b/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt index 57f31ba66f..3ea72c1bb3 100644 --- a/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt +++ b/tests/phpt/serialize_callable_that_makes_autoloader_throw.phpt @@ -20,6 +20,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + function testSerialization(int $depth = 3) { $serializer = new Serializer(new Options(), $depth); diff --git a/tests/phpt/test_callable_serialization.phpt b/tests/phpt/test_callable_serialization.phpt index 416628c585..ff5a2d79e4 100644 --- a/tests/phpt/test_callable_serialization.phpt +++ b/tests/phpt/test_callable_serialization.phpt @@ -27,6 +27,8 @@ while (!file_exists($vendor . '/vendor')) { require $vendor . '/vendor/autoload.php'; +error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + $transport = new class implements TransportInterface { public function send(Event $event): Result { From 4de6f17ec1bd04df48c4661f9af98a1ccfe99de9 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Mar 2026 19:06:52 +0100 Subject: [PATCH 2/4] fix --- src/Options.php | 1 + tests/Metrics/TraceMetricsTest.php | 40 +++++++++++++++--------------- tests/OptionsTest.php | 7 ------ 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/Options.php b/src/Options.php index 86e03fefe0..bf3f3c63ec 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1022,6 +1022,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('server_name', 'string'); $resolver->setAllowedTypes('before_send', ['callable']); $resolver->setAllowedTypes('before_send_transaction', ['callable']); + $resolver->setAllowedTypes('before_send_check_in', ['callable']); $resolver->setAllowedTypes('before_send_log', 'callable'); $resolver->setAllowedTypes('before_send_metric', ['callable']); $resolver->setAllowedTypes('ignore_exceptions', 'string[]'); diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index 972eab9cd8..b7c495b624 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -14,7 +14,7 @@ use Sentry\Options; use Sentry\State\HubAdapter; -use function Sentry\traceMetrics; +use function Sentry\metrics; final class TraceMetricsTest extends TestCase { @@ -26,9 +26,9 @@ protected function setUp(): void public function testCounterMetrics(): void { - traceMetrics()->count('test-count', 2, ['foo' => 'bar']); - traceMetrics()->count('test-count', 2, ['foo' => 'bar']); - traceMetrics()->flush(); + metrics()->count('test-count', 2, ['foo' => 'bar']); + metrics()->count('test-count', 2, ['foo' => 'bar']); + metrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -43,8 +43,8 @@ public function testCounterMetrics(): void public function testGaugeMetrics(): void { - traceMetrics()->gauge('test-gauge', 10, ['foo' => 'bar']); - traceMetrics()->flush(); + metrics()->gauge('test-gauge', 10, ['foo' => 'bar']); + metrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -59,8 +59,8 @@ public function testGaugeMetrics(): void public function testDistributionMetrics(): void { - traceMetrics()->distribution('test-distribution', 10, ['foo' => 'bar']); - traceMetrics()->flush(); + metrics()->distribution('test-distribution', 10, ['foo' => 'bar']); + metrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $this->assertCount(1, $event->getMetrics()); @@ -75,9 +75,9 @@ public function testDistributionMetrics(): void public function testMetricsBufferFull(): void { for ($i = 0; $i < MetricsAggregator::METRICS_BUFFER_SIZE + 100; ++$i) { - traceMetrics()->count('test', 1, ['foo' => 'bar']); + metrics()->count('test', 1, ['foo' => 'bar']); } - traceMetrics()->flush(); + metrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $metrics = $event->getMetrics(); @@ -90,8 +90,8 @@ public function testEnableMetrics(): void 'enable_metrics' => false, ]), StubTransport::getInstance())); - traceMetrics()->count('test-count', 2, ['foo' => 'bar']); - traceMetrics()->flush(); + metrics()->count('test-count', 2, ['foo' => 'bar']); + metrics()->flush(); $this->assertEmpty(StubTransport::$events); } @@ -106,8 +106,8 @@ public function testBeforeSendMetricAltersContent() }, ]), StubTransport::getInstance())); - traceMetrics()->count('test-count', 2, ['foo' => 'bar']); - traceMetrics()->flush(); + metrics()->count('test-count', 2, ['foo' => 'bar']); + metrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -119,8 +119,8 @@ public function testBeforeSendMetricAltersContent() public function testIntType() { - traceMetrics()->count('test-count', 2, ['foo' => 'bar']); - traceMetrics()->flush(); + metrics()->count('test-count', 2, ['foo' => 'bar']); + metrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -134,8 +134,8 @@ public function testIntType() public function testFloatType(): void { - traceMetrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); - traceMetrics()->flush(); + metrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); + metrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -150,8 +150,8 @@ public function testFloatType(): void public function testInvalidTypeIsDiscarded(): void { // @phpstan-ignore-next-line - traceMetrics()->count('test-count', 'test-value'); - traceMetrics()->flush(); + metrics()->count('test-count', 'test-value'); + metrics()->flush(); $this->assertEmpty(StubTransport::$events); } diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index d2e6043994..da08699001 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -268,13 +268,6 @@ static function (): void {}, 'enableStrictTraceContinuation', ]; - yield [ - 'strict_trace_propagation', - true, - 'isStrictTracePropagationEnabled', - 'enableStrictTracePropagation', - ]; - yield [ 'before_breadcrumb', static function (): void {}, From 8f3fff05d6c12e1e868a6692ab12da8dea2f8277 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Mon, 16 Mar 2026 19:12:57 +0100 Subject: [PATCH 3/4] fix --- composer.json | 2 +- phpstan-baseline.neon | 5 ----- src/State/RuntimeContextManager.php | 9 --------- src/Tracing/DynamicSamplingContext.php | 2 +- src/Tracing/Traits/TraceHeaderParserTrait.php | 4 ---- tests/ClientReport/ClientReportAggregatorTest.php | 3 ++- tests/SentrySdkTest.php | 2 +- tests/Tracing/StrictTraceContinuationTest.php | 7 ------- 8 files changed, 5 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index ad8c47f3fc..a0a48782ec 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "tests": "vendor/bin/phpunit --verbose", "cs-check": "vendor/bin/php-cs-fixer fix --verbose --diff --dry-run", "cs-fix": "vendor/bin/php-cs-fixer fix --verbose --diff", - "phpstan": "vendor/bin/phpstan analyse", + "phpstan": "vendor/bin/phpstan analyse --memory-limit 1G", "psalm": "vendor/bin/psalm" }, "config": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 84b38ef08a..4789dc9463 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -228,11 +228,6 @@ parameters: count: 1 path: src/Options.php - - - message: "#^Method Sentry\\\\Options\\:\\:isStrictTracePropagationEnabled\\(\\) should return bool but returns mixed\\.$#" - count: 1 - path: src/Options.php - - message: "#^Method Sentry\\\\Options\\:\\:shouldAttachStacktrace\\(\\) should return bool but returns mixed\\.$#" count: 1 diff --git a/src/State/RuntimeContextManager.php b/src/State/RuntimeContextManager.php index 43b46fb65b..cec72ee48e 100644 --- a/src/State/RuntimeContextManager.php +++ b/src/State/RuntimeContextManager.php @@ -5,7 +5,6 @@ namespace Sentry\State; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Sentry\Tracing\PropagationContext; /** @@ -187,10 +186,6 @@ private function flushRuntimeContextResources(RuntimeContext $runtimeContext, ?i $client = $hub->getClient(); - if ($client === null) { - return; - } - // Custom transports may throw from close(); endContext must stay best-effort and non-fatal. try { $client->flush($timeout); @@ -251,10 +246,6 @@ private function getLoggerFromHub(HubInterface $hub): LoggerInterface { $client = $hub->getClient(); - if ($client === null) { - return new NullLogger(); - } - return $client->getOptions()->getLoggerOrNullLogger(); } diff --git a/src/Tracing/DynamicSamplingContext.php b/src/Tracing/DynamicSamplingContext.php index 100bcba67b..410b4fc533 100644 --- a/src/Tracing/DynamicSamplingContext.php +++ b/src/Tracing/DynamicSamplingContext.php @@ -184,7 +184,7 @@ public static function fromTransaction(Transaction $transaction, HubInterface $h public static function fromOptions(Options $options, Scope $scope): self { $samplingContext = new self(); - $samplingContext->set('trace_id', (string)$scope->getPropagationContext()->getTraceId()); + $samplingContext->set('trace_id', (string) $scope->getPropagationContext()->getTraceId()); $samplingContext->set('sample_rand', (string) $scope->getPropagationContext()->getSampleRand()); if ($options->getTracesSampleRate() !== null) { diff --git a/src/Tracing/Traits/TraceHeaderParserTrait.php b/src/Tracing/Traits/TraceHeaderParserTrait.php index 1a5c0f7c08..4b1d494dcc 100644 --- a/src/Tracing/Traits/TraceHeaderParserTrait.php +++ b/src/Tracing/Traits/TraceHeaderParserTrait.php @@ -117,10 +117,6 @@ private static function shouldContinueTrace(DynamicSamplingContext $samplingCont $hub = SentrySdk::getCurrentHub(); $client = $hub->getClient(); - if ($client === null) { - return true; - } - $options = $client->getOptions(); $clientOrgId = $options->getOrgId(); if ($clientOrgId === null && $options->getDsn() !== null) { diff --git a/tests/ClientReport/ClientReportAggregatorTest.php b/tests/ClientReport/ClientReportAggregatorTest.php index ce82c4d50a..7766c7ea50 100644 --- a/tests/ClientReport/ClientReportAggregatorTest.php +++ b/tests/ClientReport/ClientReportAggregatorTest.php @@ -8,6 +8,7 @@ use Sentry\Client; use Sentry\ClientReport\ClientReportAggregator; use Sentry\ClientReport\Reason; +use Sentry\NoOpClient; use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; @@ -102,7 +103,7 @@ public function testZeroQuantityDiscarded(): void public function testNegativeQuantityDiscardedWhenNoClientIsBound(): void { - SentrySdk::setCurrentHub(new Hub()); + SentrySdk::setCurrentHub(new Hub(new NoOpClient())); ClientReportAggregator::getInstance()->add(DataCategory::profile(), Reason::eventProcessor(), -10); diff --git a/tests/SentrySdkTest.php b/tests/SentrySdkTest.php index 633881bdf0..3ce3d6f12d 100644 --- a/tests/SentrySdkTest.php +++ b/tests/SentrySdkTest.php @@ -6,9 +6,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Sentry\NoOpClient; use Sentry\ClientInterface; use Sentry\Event; +use Sentry\NoOpClient; use Sentry\Options; use Sentry\SentrySdk; use Sentry\State\Hub; diff --git a/tests/Tracing/StrictTraceContinuationTest.php b/tests/Tracing/StrictTraceContinuationTest.php index a92a1eab7f..6de0a3a962 100644 --- a/tests/Tracing/StrictTraceContinuationTest.php +++ b/tests/Tracing/StrictTraceContinuationTest.php @@ -16,13 +16,6 @@ final class StrictTraceContinuationTest extends TestCase { private const SENTRY_TRACE_HEADER = '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1'; - protected function setUp(): void - { - parent::setUp(); - - SentrySdk::setCurrentHub(new Hub()); - } - /** * @dataProvider strictTraceContinuationDataProvider */ From 5128f36a7f58ed65b82d8e14b89c507f1b9a71a3 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Wed, 18 Mar 2026 11:12:00 +0100 Subject: [PATCH 4/4] fix --- src/Options.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Options.php b/src/Options.php index bf3f3c63ec..1ed6f0144e 100644 --- a/src/Options.php +++ b/src/Options.php @@ -1029,7 +1029,6 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('ignore_transactions', 'string[]'); $resolver->setAllowedTypes('trace_propagation_targets', ['null', 'string[]']); $resolver->setAllowedTypes('strict_trace_continuation', 'bool'); - $resolver->setAllowedTypes('strict_trace_propagation', 'bool'); $resolver->setAllowedTypes('tags', 'string[]'); $resolver->setAllowedTypes('error_types', ['null', 'int']); $resolver->setAllowedTypes('max_breadcrumbs', 'int'); @@ -1111,7 +1110,6 @@ private function configureOptions(OptionsResolver $resolver): void }, 'trace_propagation_targets' => null, 'strict_trace_continuation' => false, - 'strict_trace_propagation' => false, 'tags' => [], 'error_types' => null, 'max_breadcrumbs' => self::DEFAULT_MAX_BREADCRUMBS,