diff --git a/.gitattributes b/.gitattributes index 5b30cfa974..be6d279570 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,6 +12,8 @@ # Exclude non-essential files from dist /tests export-ignore /stubs export-ignore +/AGENTS.md export-ignore +/CLAUDE.md export-ignore /.appveyor.yml export-ignore /.craft.yml export-ignore /.editorconfig export-ignore diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index 30c6083c6b..c6dd82320b 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -14,5 +14,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@3e6a0f477702864bb5854384b390a0db3325428e # v2 secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2939342fb5..49052d375d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: ${{ matrix.php.version }} coverage: xdebug @@ -65,7 +65,7 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ steps.composer-cache.outputs.directory }} key: ${{ runner.os }}-${{ matrix.php.version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }} @@ -73,23 +73,38 @@ jobs: # These dependencies are not used running the tests but can cause deprecation warnings so we remove them before running the tests - name: Remove unused dependencies - run: composer remove vimeo/psalm phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update + run: composer remove carthage-software/mago phpstan/phpstan friendsofphp/php-cs-fixer --dev --no-interaction --no-update - name: Remove RoadRunner dependencies on unsupported PHP versions if: ${{ matrix.os == 'windows-latest' || matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} run: composer remove spiral/roadrunner-http spiral/roadrunner-worker --dev --no-interaction --no-update + - name: Remove OpenTelemetry dependencies on unsupported PHP versions + if: ${{ matrix.php.version == '7.2' || matrix.php.version == '7.3' || matrix.php.version == '7.4' || matrix.php.version == '8.0' }} + run: composer remove open-telemetry/api open-telemetry/exporter-otlp open-telemetry/sdk --dev --no-interaction --no-update + - name: Set phpunit/phpunit version constraint run: composer require phpunit/phpunit:'${{ matrix.php.phpunit }}' --dev --no-interaction --no-update - - name: Install highest dependencies - run: composer update --no-progress --no-interaction --prefer-dist + - name: Resolve highest dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit if: ${{ matrix.dependencies == 'highest' }} - - name: Install lowest dependencies - run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest + - name: Resolve lowest dependencies + run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest --no-install --no-plugins --no-scripts --no-audit if: ${{ matrix.dependencies == 'lowest' }} + - name: Audit highest dependencies + run: composer audit --locked --no-interaction --format=table + if: ${{ matrix.dependencies == 'highest' }} + + - name: Audit lowest dependencies + run: composer audit --locked --no-interaction --format=table --abandoned=report + if: ${{ matrix.dependencies == 'lowest' }} + + - name: Install dependencies + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts + - name: Run unit tests run: vendor/bin/phpunit --testsuite unit --coverage-clover=coverage.xml # The reason for running some OOM tests without coverage is that because the coverage information collector can cause another OOM event invalidating the test @@ -97,26 +112,22 @@ jobs: run: vendor/bin/phpunit --testsuite oom --no-coverage - name: Upload code coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} - - name: Check benchmarks - run: vendor/bin/phpbench run --revs=1 --iterations=1 - if: ${{ matrix.dependencies == 'highest' && matrix.php.version == '8.4' }} - runtime-tests-frankenphp: name: Runtime tests (FrankenPHP) runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' coverage: none @@ -127,14 +138,20 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer install --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Install FrankenPHP env: @@ -176,12 +193,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' coverage: none @@ -192,14 +209,20 @@ jobs: shell: bash - name: Cache Composer dependencies - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer install --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Install RoadRunner env: @@ -238,4 +261,3 @@ jobs: - 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 c97ec88abf..dbf9b4e14a 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -24,18 +24,18 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@d630201930c7fe5aee6366ebee19ebb681128512 + uses: getsentry/craft@3e6a0f477702864bb5854384b390a0db3325428e env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index ba6ffe12cf..58b41ad154 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -16,15 +16,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer update --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Run script run: vendor/bin/php-cs-fixer fix --verbose --diff --dry-run @@ -34,33 +40,45 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: php-version: '8.4' + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table + - name: Install dependencies - run: composer update --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Run script run: vendor/bin/phpstan analyse - psalm: - name: Psalm + mago: + name: Mago runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 with: - php-version: '8.3' + php-version: '8.4' + + - name: Resolve dependencies + run: composer update --no-progress --no-interaction --prefer-dist --no-install --no-plugins --no-scripts --no-audit + + - name: Audit dependencies + run: composer audit --locked --no-interaction --format=table - name: Install dependencies - run: composer update --no-progress --no-interaction --prefer-dist + run: composer install --no-progress --no-interaction --prefer-dist --no-plugins --no-scripts - name: Run script - run: vendor/bin/psalm + run: composer mago diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 0000000000..c4e38e4d6c --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,16 @@ +name: Validate PR + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + validate-pr: + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + steps: + - uses: getsentry/github-workflows/validate-pr@c802283cd9075b7a2b7a32655019c21c21676e34 + with: + app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} + private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ea268f4e2e..ef346b3c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,109 @@ # CHANGELOG +## 4.27.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.27.0. + +### Features + +- Add `profiles_sampler` option. [(#2082)](https://github.com/getsentry/sentry-php/pull/2082) + +### Bug Fixes + +- Preserve manually configured user attributes on logs and metrics when `send_default_pii` is disabled. [(#2083)](https://github.com/getsentry/sentry-php/pull/2083) + +### Misc + +- Add Mago static analysis to CI. [(#2020)](https://github.com/getsentry/sentry-php/pull/2020) + +## 4.26.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.26.0. + +### Features + +- Add `AgentClient` and `AgentClientBuilder` to hand off envelopes to a local Sentry agent. [(#2062)](https://github.com/getsentry/sentry-php/pull/2062) +- Add fallback HTTP delivery for `AgentClient` when the local Sentry agent is unavailable. [(#2072)](https://github.com/getsentry/sentry-php/pull/2072) +- Add `LogToSentryIssueHandler` Monolog handler to capture log messages as Sentry issues. [(#2075)](https://github.com/getsentry/sentry-php/pull/2075) + +### Bug Fixes + +- Respect `send_default_pii` before attaching user attributes to Sentry logs. [(#2076)](https://github.com/getsentry/sentry-php/pull/2076) +- Ignore invalid propagated `sentry-sample_rand` baggage values and generate a valid sample random value instead. [(#2077)](https://github.com/getsentry/sentry-php/pull/2077) + +## 4.25.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.25.0. + +### Features + +- Add `ExceptionToSentryIssueHandler` Monolog handler that only captures exceptions as Sentry issues without converting log messages to errors. [(#2061)](https://github.com/getsentry/sentry-php/pull/2061) +- Add `metric_flush_threshold` option to automatically flush buffered metrics after a configured number of metric records. [(#2059)](https://github.com/getsentry/sentry-php/pull/2059) + +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'metric_flush_threshold' => 50, +]); +``` + +### Bug Fixes + +- Prevent PHP warnings when trying to increase the memory limit for out-of-memory error handling. [(#2063)](https://github.com/getsentry/sentry-php/pull/2063) + +### Misc + +- Use a `RingBuffer` for log storage when `log_flush_threshold` is not set to prevent unbounded memory growth, with a hard cap of 1000 records. [(#2058)](https://github.com/getsentry/sentry-php/pull/2058) +- Add `ext-excimer` as a Composer suggestion to surface its requirement for profiling. [(#2057)](https://github.com/getsentry/sentry-php/pull/2057) + +## 4.24.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.24.0. + +### Bug Fixes + +- Serialize native PHP enums as readable enum strings, including backed enum values, instead of opaque `Object` strings. [(#2038)](https://github.com/getsentry/sentry-php/pull/2038) +- Exclude `AGENTS.md` and `CLAUDE.md` from distribution archives. [(#2046)](https://github.com/getsentry/sentry-php/pull/2046) + +### Misc + +- Deprecate `Sentry\Monolog\Handler` in favor of `Sentry\Monolog\LogsHandler` with the `enable_logs` SDK option. [(#2051)](https://github.com/getsentry/sentry-php/pull/2051) + +## 4.23.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.23.1. + +### Bug Fixes + +- Use `server.address` log attribute instead of `sentry.server.address`. [(#2040)](https://github.com/getsentry/sentry-php/pull/2040) + +## 4.23.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.23.0. + +### Features + +- Add `OTLPIntegration` support to interoperate with OpenTelemetry traces. [(#2030)](https://github.com/getsentry/sentry-php/pull/2030) + +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'integrations' => [ + new \Sentry\Integration\OTLPIntegration(), + ], +]); +``` + +- Add `log_flush_threshold` to automatically flush buffered logs after a configured number of log records. [(#2032)](https://github.com/getsentry/sentry-php/pull/2032) +```php +\Sentry\init([ + 'dsn' => '__YOUR_DSN__', + 'enable_logs' => true, + 'log_flush_threshold' => 20, +]); +``` + + ## 4.22.0 The Sentry SDK team is happy to announce the immediate availability of Sentry PHP SDK v4.22.0. diff --git a/analysis-baseline.toml b/analysis-baseline.toml new file mode 100644 index 0000000000..9513a4e36f --- /dev/null +++ b/analysis-baseline.toml @@ -0,0 +1,1819 @@ +variant = "loose" + +[[issues]] +file = "src/Agent/Transport/AgentClient.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Agent/Transport/AgentClient.php" +code = "redundant-cast" +message = "Redundant cast to `(string)`: the expression already has this type." +count = 1 + +[[issues]] +file = "src/Client.php" +code = "implicit-to-string-cast" +message = 'Implicit conversion to `string` for left operand via `Sentry\Severity::__toString()`.' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Sentry\StacktraceBuilder::buildFromBacktrace`: expected `list, 'class'?: class-string, 'file'?: string, 'function'?: string, 'line'?: int, 'type'?: string}>`, but possibly received `array`.''' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "possibly-null-operand" +message = 'Possibly null left operand used in string concatenation (type `Sentry\Severity|null`).' +count = 1 + +[[issues]] +file = "src/Client.php" +code = "redundant-docblock-type" +message = "Redundant docblock type for variable `$result`." +count = 1 + +[[issues]] +file = "src/Client.php" +code = "unused-template-parameter" +message = 'Template parameter `T` is never used in method `Sentry\Client::getIntegration`.' +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #1 of `Sentry\ClientReport\DiscardedEvent::__construct`: expected `string`, but provided type `array-key` is less specific.' +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 3 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "redundant-comparison" +message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/ClientReport/ClientReportAggregator.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is always truthy and right operand is evaluated." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-false-operand" +message = "Left operand in `==` comparison might be `false` (type `false|int`)." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #1 of function `strrpos` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #1 of function `substr` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #2 of function `explode` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = "Argument #2 of function `preg_match` is possibly `null`, but parameter type `string` does not accept it." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Dsn::__construct` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = 'Argument #5 of method `Sentry\Dsn::__construct` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-null-argument" +message = 'Argument #6 of method `Sentry\Dsn::__construct` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'host' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 2 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'path' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 4 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'scheme' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "possibly-undefined-string-array-index" +message = "Possibly undefined array key 'user' accessed on `array{'fragment'?: non-empty-string, 'host'?: non-empty-string, 'pass'?: non-empty-string, 'path'?: non-empty-string, 'port'?: int<0, 65535>, 'query'?: non-empty-string, 'scheme'?: non-empty-string, 'user'?: non-empty-string}`." +count = 1 + +[[issues]] +file = "src/Dsn.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 2 + +[[issues]] +file = "src/Dsn.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 2 + +[[issues]] +file = "src/Dsn.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "deprecated-method" +message = "Call to deprecated method: `ReflectionProperty::setAccessible`." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "less-specific-nested-argument-type" +message = '''Argument type mismatch for argument #1 of `Closure::fromCallable`: expected `(callable(...mixed=): mixed)`, but provided type `list{Sentry\ErrorHandler&static|sentry\errorhandler, string('handleError')}` is less specific.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "less-specific-nested-argument-type" +message = '''Argument type mismatch for argument #1 of `Closure::fromCallable`: expected `(callable(...mixed=): mixed)`, but provided type `list{Sentry\ErrorHandler&static|sentry\errorhandler, string('handleException')}` is less specific.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "less-specific-nested-argument-type" +message = '''Argument type mismatch for argument #1 of `Closure::fromCallable`: expected `(callable(...mixed=): mixed)`, but provided type `list{Sentry\ErrorHandler&static|sentry\errorhandler, string('handleFatalError')}` is less specific.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Sentry\ErrorHandler::cleanBacktraceFromErrorHandlerFrames`: expected `list, 'class'?: class-string, 'file'?: string, 'function'?: string, 'line'?: int, 'type'?: string}>`, but possibly received `array`.''' +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "redundant-comparison" +message = "Redundant `>=` comparison: left-hand side is always greater than or equal to right-hand side." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/ErrorHandler.php" +code = "write-only-property" +message = "Property `$reservedMemory` is written to but never read." +count = 1 + +[[issues]] +file = "src/Event.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Event.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Event.php" +code = "no-value" +message = 'Argument #1 passed to method `Sentry\Util\DebugType::getDebugType` has type `never`, meaning it cannot produce a value.' +count = 1 + +[[issues]] +file = "src/EventHint.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 4 + +[[issues]] +file = "src/EventHint.php" +code = "no-value" +message = 'Argument #1 passed to method `Sentry\Util\DebugType::getDebugType` has type `never`, meaning it cannot produce a value.' +count = 4 + +[[issues]] +file = "src/EventHint.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 3 + +[[issues]] +file = "src/EventHint.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$extra` is already `array`." +count = 1 + +[[issues]] +file = "src/FrameBuilder.php" +code = "invalid-operand" +message = "Right operand in `&&` operation is an `array`." +count = 1 + +[[issues]] +file = "src/FrameBuilder.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/FrameBuilder.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "deprecated-function" +message = "Call to deprecated function: `curl_close`." +count = 2 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 2 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\HttpClient\Response::__construct`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Util\Http::parseResponseHeaders`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-argument" +message = "Invalid argument type for argument #1 of `version_compare`: expected `string`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "possibly-false-array-access" +message = "Cannot perform array access on possibly `false` value." +count = 1 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "redundant-comparison" +message = "Redundant `<` comparison: left-hand side is never less than right-hand side." +count = 2 + +[[issues]] +file = "src/HttpClient/HttpClient.php" +code = "too-many-arguments" +message = "Too many arguments provided for function `curl_share_init_persistent`." +count = 1 + +[[issues]] +file = "src/HttpClient/Request.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/HttpClient/Response.php" +code = "less-specific-argument" +message = "Argument type mismatch for argument #1 of `strtolower`: expected `string`, but provided type `array-key` is less specific." +count = 1 + +[[issues]] +file = "src/HttpClient/Response.php" +code = "property-type-coercion" +message = "A value of a less specific type `array{'': array-key, ...}` is being assigned to property `$$headerNames` (array)." +count = 1 + +[[issues]] +file = "src/Integration/FrameContextifierIntegration.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Integration\FrameContextifierIntegration::addContextToStacktraceFrames` is possibly `null`, but parameter type `Sentry\Stacktrace` does not accept it.' +count = 1 + +[[issues]] +file = "src/Integration/FrameContextifierIntegration.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Integration\FrameContextifierIntegration::getSourceCodeExcerpt` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "invalid-property-assignment-value" +message = 'Invalid type for property `$integrations`: expected `array, bool>`, but got `array|string, bool>`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Integration\IntegrationRegistry::getIntegrationsToSetup`: expected `array`, but found `list<(callable(array): Sentry\Integration\IntegrationInterface)|Sentry\Integration\IntegrationInterface>`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Integration\IntegrationRegistry::setupIntegrations`: expected `array, Sentry\Integration\IntegrationInterface>`, but found `array`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `array_map`: expected `(callable((callable(array): Sentry\Integration\IntegrationInterface)|object): string)|(callable((callable(array): Sentry\Integration\IntegrationInterface)|object, ('S.array_map() extends mixed)): string)|null`, but possibly received `string('get_class')`.''' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `get_class`: expected `object`, but possibly received `(callable(array): Sentry\Integration\IntegrationInterface)|Sentry\Integration\IntegrationInterface`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "redundant-type-comparison" +message = 'Redundant type assertion: `$userIntegrations` is already `array): Sentry\Integration\IntegrationInterface)|Sentry\Integration\IntegrationInterface>`.' +count = 1 + +[[issues]] +file = "src/Integration/IntegrationRegistry.php" +code = "unreachable-else-clause" +message = "Unreachable else clause" +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #1 of `Sentry\Integration\RequestIntegration::parseUploadedFiles`: expected `array`, but provided type `array` is less specific.' +count = 2 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "less-specific-return-statement" +message = 'Returned type `array>` is less specific than the declared return type `array>` for function `Sentry\Integration\RequestIntegration::sanitizeHeaders`.' +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::createFromUserIpAddress`: expected `string`, but found `truthy-mixed`.' +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setIpAddress`: expected `null|string`, but found `truthy-mixed`.' +count = 1 + +[[issues]] +file = "src/Integration/RequestIntegration.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugFileLogger::log()` expects type `string` but parent `Psr\Log\LoggerInterface::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugFileLogger::log()` expects type `string` but parent `Psr\Log\LoggerTrait::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugLogger::log()` expects type `string` but parent `Psr\Log\LoggerInterface::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugLogger::log()` expects type `string` but parent `Psr\Log\LoggerTrait::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugStdOutLogger::log()` expects type `string` but parent `Psr\Log\LoggerInterface::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "incompatible-parameter-type" +message = 'Parameter `$message` of `Sentry\Logger\DebugStdOutLogger::log()` expects type `string` but parent `Psr\Log\LoggerTrait::log()` expects type `Stringable|string`' +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Logger/DebugLogger.php" +code = "redundant-cast" +message = "Redundant cast to `(string)`: the expression already has this type." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "no-value" +message = "Argument #2 passed to function `sprintf` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/Logs/LogsAggregator.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$key` is already `string`." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "ambiguous-instantiation-target" +message = "Ambiguous instantiation: the expression used with `new` can resolve to multiple different classes." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Util\TelemetryStorage::push`: expected `Sentry\Metrics\Types\Metric`, but found `nonnull`.' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #5 of `Sentry\Metrics\Types\CounterMetric::__construct`: expected `array`, but possibly received `array{'sentry.environment': scalar, 'sentry.release'?: scalar, 'sentry.sdk.name'?: scalar, 'sentry.sdk.version'?: scalar, 'server.address': scalar, 'user.email'?: null|scalar, 'user.id'?: null|scalar, 'user.name'?: null|scalar, ...}`.''' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #5 of `Sentry\Metrics\Types\DistributionMetric::__construct`: expected `array`, but possibly received `array{'sentry.environment': scalar, 'sentry.release'?: scalar, 'sentry.sdk.name'?: scalar, 'sentry.sdk.version'?: scalar, 'server.address': scalar, 'user.email'?: null|scalar, 'user.id'?: null|scalar, 'user.name'?: null|scalar, ...}`.''' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #5 of `Sentry\Metrics\Types\GaugeMetric::__construct`: expected `array`, but possibly received `array{'sentry.environment': scalar, 'sentry.release'?: scalar, 'sentry.sdk.name'?: scalar, 'sentry.sdk.version'?: scalar, 'server.address': scalar, 'user.email'?: null|scalar, 'user.id'?: null|scalar, 'user.name'?: null|scalar, ...}`.''' +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 3 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 3 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/Metrics/MetricsAggregator.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$value` is already `float`." +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "impossible-nonnull-entry-check" +message = "Impossible `isset` check on key `'context'` accessed on `array{'channel': string, 'datetime': DateTimeImmutable, 'extra'?: array, 'level': int, 'message': string}`." +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #5 of `Sentry\Breadcrumb::__construct`: expected `array`, but provided type `array|int` is less specific.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractHandler::__construct`: expected `enum(Monolog\Level)|int(100)|int(200)|int(250)|int(300)|int(400)|int(500)|int(550)|int(600)|string('ALERT')|string('CRITICAL')|string('DEBUG')|string('EMERGENCY')|string('ERROR')|string('INFO')|string('NOTICE')|string('WARNING')|string('alert')|string('critical')|string('debug')|string('emergency')|string('error')|string('info')|string('notice')|string('warning')`, but possibly received `enum(Monolog\Level)|int|string`.''' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Sentry\Monolog\BreadcrumbHandler::getBreadcrumbLevel`: expected `enum(Monolog\Level)|int`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Sentry\Monolog\BreadcrumbHandler::getBreadcrumbType`: expected `int`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #3 of `Sentry\Breadcrumb::__construct`: expected `string`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #4 of `Sentry\Breadcrumb::__construct`: expected `null|string`, but possibly received `DateTimeImmutable|array|int|string`.' +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-operand" +message = "Possibly invalid type for left operand." +count = 1 + +[[issues]] +file = "src/Monolog/BreadcrumbHandler.php" +code = "possibly-invalid-operand" +message = "Possibly invalid type for right operand." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityLogLevelTrait.php" +code = "duplicate-definition" +message = 'Trait `Sentry\Monolog\CompatibilityLogLevelTrait` is already defined elsewhere.' +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityLogLevelTrait.php" +code = "redundant-comparison" +message = "Redundant `>=` comparison: left-hand side is always greater than or equal to right-hand side." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityLogLevelTrait.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityProcessingHandlerTrait.php" +code = "duplicate-definition" +message = 'Trait `Sentry\Monolog\CompatibilityProcessingHandlerTrait` is already defined elsewhere.' +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityProcessingHandlerTrait.php" +code = "redundant-comparison" +message = "Redundant `>=` comparison: left-hand side is always greater than or equal to right-hand side." +count = 1 + +[[issues]] +file = "src/Monolog/CompatibilityProcessingHandlerTrait.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Monolog/ExceptionToSentryIssueHandler.php" +code = "less-specific-return-statement" +message = 'Returned type `array` is less specific than the declared return type `array` for function `Sentry\Monolog\ExceptionToSentryIssueHandler::getArrayFieldFromRecord`.' +count = 1 + +[[issues]] +file = "src/Monolog/ExceptionToSentryIssueHandler.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Monolog/ExceptionToSentryIssueHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractHandler::isHandling`: expected `Monolog\LogRecord`, but possibly received `Monolog\LogRecord|array`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = '''Invalid argument type for argument #1 of `Monolog\Handler\AbstractHandler::__construct`: expected `enum(Monolog\Level)|int(100)|int(200)|int(250)|int(300)|int(400)|int(500)|int(550)|int(600)|string('ALERT')|string('CRITICAL')|string('DEBUG')|string('EMERGENCY')|string('ERROR')|string('INFO')|string('NOTICE')|string('WARNING')|string('alert')|string('critical')|string('debug')|string('emergency')|string('error')|string('info')|string('notice')|string('warning')`, but found `mixed`.''' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Event::setMessage`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\CompatibilityProcessingHandlerTrait::getSeverityFromLevel`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\Handler::getMonologContextData`: expected `array`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\Handler::getMonologExtraData`: expected `array`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/Handler.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "less-specific-return-statement" +message = 'Returned type `array` is less specific than the declared return type `array` for function `Sentry\Monolog\LogToSentryIssueHandler::getArrayFieldFromRecord`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Event::setMessage`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\CompatibilityProcessingHandlerTrait::getSeverityFromLevel`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractHandler::isHandling`: expected `Monolog\LogRecord`, but possibly received `Monolog\LogRecord|array`.' +count = 2 + +[[issues]] +file = "src/Monolog/LogToSentryIssueHandler.php" +code = "possibly-invalid-argument" +message = 'Possible argument type mismatch for argument #1 of `Monolog\Handler\AbstractProcessingHandler::handle`: expected `Monolog\LogRecord`, but possibly received `Monolog\LogRecord|array`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Monolog\CompatibilityLogLevelTrait::getSentryLogLevelFromMonologLevel`: expected `int`, but found `mixed`.' +count = 2 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #1 of `array_merge`: expected `array`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #2 of `Sentry\Logs\LogsAggregator::add`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `array_merge`: expected `array`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Monolog/LogsHandler.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 2 + +[[issues]] +file = "src/NoOpClient.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/NoOpClient.php" +code = "redundant-comparison" +message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-operand" +message = "Right operand in `||` operation has `mixed` type." +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeBreadcrumbCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendCheckInCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendLogCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getBeforeSendTransactionCallback`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getClassSerializers`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getContextLines`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getDsn`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getEnableLogs`. Saw type `nonnull`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getEnvironment`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getErrorTypes`. Saw type `nonnull`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpClient`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpConnectTimeout`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpProxyAuthentication`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpProxy`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpSslNativeCa`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpSslVerifyPeer`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getHttpTimeout`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getIgnoreExceptions`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getIgnoreTransactions`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getInAppExcludedPaths`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getInAppIncludedPaths`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getIntegrations`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getLogger`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getMaxBreadcrumbs`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getMaxRequestBodySize`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getOrgId`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getPrefixes`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getRelease`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getSampleRate`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getServerName`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTags`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTracePropagationTargets`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTracesSampleRate`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTracesSampler`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::getTransport`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::hasDefaultIntegrations`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::isHttpCompressionEnabled`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::shouldAttachStacktrace`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::shouldCaptureSilencedErrors`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/Options.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\Options::shouldSendDefaultPii`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/OptionsResolver.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 7 + +[[issues]] +file = "src/OptionsResolver.php" +code = "mixed-property-type-coercion" +message = "A value with a less specific type `array|list{mixed}>` is being assigned to property `$$allowedTypes` (array>)." +count = 1 + +[[issues]] +file = "src/OptionsResolver.php" +code = "mixed-return-statement" +message = 'Could not infer a precise return type for function `Sentry\OptionsResolver::validateValue`. Saw type `mixed`.' +count = 1 + +[[issues]] +file = "src/OptionsResolver.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/OptionsResolver.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$allowedValue` is already `array`." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "invalid-method-access" +message = "Attempting to access a method on a non-object type (`unknown-ref(ExcimerLog)`)." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Profiling\Profile::getFormattedData`: expected `array{'device': array{'architecture': string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': string}, 'platform': string, 'profile': array{'frames': array, 'samples': array, 'stacks': array>}, 'release': string, 'runtime': array{'name': string, 'version': string}, 'timestamp': string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': string, 'trace_id': string}, 'version': string}|null`, but found `array{'device': array{'architecture': null|string}, 'environment': string, 'event_id': string, 'os': array{'build_number': string, 'name': string, 'version': null|string}, 'platform': string('php'), 'profile': array{'frames': array{}|list, 'samples': array{}|non-empty-list, 'stacks': list>}, 'release': string, 'runtime': array{'name': string, 'sapi': null|string, 'version': null|string}, 'timestamp': non-empty-string, 'transaction': array{'active_thread_id': string, 'id': string, 'name': null|string, 'trace_id': null|string}, 'version': string}`.''' +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "less-specific-nested-return-statement" +message = '''Returned type `array{}|non-empty-list` is less specific than the declared return type `array}>` for function `Sentry\Profiling\Profile::prepareStacks` due to nested 'mixed'.''' +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 2 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-class-like" +message = "Cannot find class, interface, enum, or type alias `ExcimerLog`." +count = 2 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-class-like" +message = "Class, Interface, or Trait `ExcimerLogEntry` does not exist." +count = 5 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-method" +message = "Method `gettimestamp` does not exist on type `ExcimerLogEntry`." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "non-existent-method" +message = "Method `gettrace` does not exist on type `ExcimerLogEntry`." +count = 1 + +[[issues]] +file = "src/Profiling/Profile.php" +code = "possibly-invalid-iterator" +message = "The expression provided to `foreach` (type `array}>|unknown-ref(ExcimerLog)`) might not be iterable at runtime." +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "invalid-method-access" +message = "Attempting to access a method on a non-object type (`unknown-ref(ExcimerProfiler)`)." +count = 3 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "mixed-argument" +message = '''Invalid argument type for argument #1 of `Sentry\Profiling\Profile::setExcimerLog`: expected `array}>|unknown-ref(ExcimerLog)`, but found `mixed`.''' +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "non-existent-class" +message = "Class `ExcimerProfiler` not found." +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "non-existent-class-like" +message = "Cannot find class, interface, enum, or type alias `ExcimerProfiler`." +count = 1 + +[[issues]] +file = "src/Profiling/Profiler.php" +code = "non-existent-constant" +message = "Undefined constant: `EXCIMER_REAL`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "generic-object-iteration" +message = "Iterating over a generic `object`. This will iterate its public properties." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "impossible-type-comparison" +message = "Impossible type assertion: `$callable` of type `(callable(...mixed): mixed)` can never be `object`." +count = 2 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "invalid-operand" +message = "Invalid type `scalar` for middle operand in string concatenation." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "invalid-operand" +message = "Left operand in `&&` operation is an `object`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Serializer\AbstractSerializer::serializeValue`: expected `bool|float|int|null|string`, but found `bool|null|numeric`.' +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "mixed-argument" +message = "Invalid argument type for argument #1 of `method_exists`: expected `class-string|object`, but found `nonnull`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 5 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "no-value" +message = "Argument #1 passed to function `method_exists` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "no-value" +message = "Argument #1 passed to method `ReflectionMethod::__construct` has type `never`, meaning it cannot produce a value." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "null-operand" +message = "Right operand in `!=` comparison is `null`." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "possibly-null-operand" +message = "Left operand in `!=` comparison might be `null` (type `null|string`)." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "redundant-cast" +message = "Redundant cast to `(string)`: the expression already has this type." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is always falsy and right operand is not evaluated." +count = 1 + +[[issues]] +file = "src/Serializer/AbstractSerializer.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is always truthy and right operand is evaluated." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/CheckInItem.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/EventItem.php" +code = "mixed-array-assignment" +message = "Unsafe array assignment on type `mixed`." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/EventItem.php" +code = "possibly-null-argument" +message = 'Argument #1 of method `Sentry\Util\Str::vsprintfOrNull` is possibly `null`, but parameter type `string` does not accept it.' +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/ProfileItem.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/TransactionItem.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Serializer\EnvelopItems\TransactionItem::serializeSpan`: expected `array{'data'?: array, 'description'?: string, 'op'?: string, 'origin': string, 'parent_span_id'?: string, 'span_id': string, 'start_timestamp': float, 'status'?: string, 'tags'?: array, 'timestamp'?: float, 'trace_id': string}`, but found `array{'data'?: array, 'description'?: null|string, 'op'?: null|string, 'origin': string, 'parent_span_id'?: string, 'span_id': string, 'start_timestamp': float, 'status'?: string, 'tags'?: array, 'timestamp'?: float|null, 'trace_id': string}`.''' +count = 1 + +[[issues]] +file = "src/Serializer/EnvelopItems/TransactionItem.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Serializer/PayloadSerializer.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Serializer/RepresentationSerializer.php" +code = "invalid-operand" +message = "Invalid type `never` for left operand in string concatenation." +count = 1 + +[[issues]] +file = "src/Serializer/Traits/BreadcrumbSerializerTrait.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Serializer\Traits\BreadcrumbSerializerTrait::serializeBreadcrumb`: expected `array{'category': string, 'data'?: object, 'level': string, 'message'?: string, 'timestamp': float, 'type': string}`, but found `array{'category': string, 'data'?: stdClass, 'level': string, 'message'?: null|string, 'timestamp': float, 'type': string}`.''' +count = 1 + +[[issues]] +file = "src/Serializer/Traits/StacktraceFrameSerializerTrait.php" +code = "invalid-return-statement" +message = '''Invalid return type for function `Sentry\Serializer\Traits\StacktraceFrameSerializerTrait::serializeStacktraceFrame`: expected `array{'abs_path'?: string, 'context_line'?: string, 'filename': string, 'function'?: string, 'in_app': bool, 'lineno': int, 'post_context'?: array, 'pre_context'?: array, 'raw_function'?: string, 'vars'?: array}`, but found `array{'abs_path'?: null|string, 'context_line'?: null|string, 'filename': string, 'function'?: null|string, 'in_app': bool, 'lineno': int, 'post_context'?: array, 'pre_context'?: array, 'raw_function'?: null|string, 'vars'?: array}`.''' +count = 1 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "deprecated-function" +message = "Call to deprecated function: `curl_close`." +count = 2 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 2 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\HttpClient\Response::__construct`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Spotlight/SpotlightClient.php" +code = "redundant-comparison" +message = "Redundant `<` comparison: left-hand side is never less than right-hand side." +count = 2 + +[[issues]] +file = "src/Stacktrace.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Stacktrace.php" +code = "no-value" +message = 'Argument #1 passed to method `Sentry\Util\DebugType::getDebugType` has type `never`, meaning it cannot produce a value.' +count = 1 + +[[issues]] +file = "src/StacktraceBuilder.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `Sentry\Stacktrace::__construct`: expected `array`, but provided type `non-empty-list` is less specific.' +count = 1 + +[[issues]] +file = "src/StacktraceBuilder.php" +code = "possibly-invalid-argument" +message = '''Possible argument type mismatch for argument #1 of `Sentry\StacktraceBuilder::buildFromBacktrace`: expected `list, 'class'?: class-string, 'file'?: string, 'function'?: string, 'line'?: int, 'type'?: string}>`, but possibly received `array`.''' +count = 1 + +[[issues]] +file = "src/State/Hub.php" +code = "mixed-operand" +message = "Right operand in `<` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/State/Hub.php" +code = "possibly-null-operand" +message = "Left operand in `<` comparison might be `null` (type `float|null`)." +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 2 + +[[issues]] +file = "src/State/Scope.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 3 + +[[issues]] +file = "src/State/Scope.php" +code = "mixed-property-type-coercion" +message = 'A value with a less specific type `non-empty-array|non-empty-list<(callable(...mixed): mixed)>` is being assigned to property `$$eventProcessors` (array).' +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "no-value" +message = 'Argument #1 passed to method `Sentry\Util\DebugType::getDebugType` has type `never`, meaning it cannot produce a value.' +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/State/Scope.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/Tracing/DynamicSamplingContext.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #2 of `Sentry\Tracing\DynamicSamplingContext::set`: expected `string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Tracing/DynamicSamplingContext.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 4 + +[[issues]] +file = "src/Tracing/DynamicSamplingContext.php" +code = "possibly-null-argument" +message = 'Argument #2 of method `Sentry\Tracing\DynamicSamplingContext::set` is possibly `null`, but parameter type `string` does not accept it.' +count = 2 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "implicit-to-string-cast" +message = 'Implicit conversion to `string` for right operand via `Psr\Http\Message\UriInterface::__toString()`.' +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\Tracing\SpanStatus::createFromHttpStatusCode`: expected `int`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-method-access" +message = "Attempting to access a method on a non-object type (`mixed`)." +count = 2 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-method-access" +message = "Attempting to access a method on a non-object type (`nonnull`)." +count = 7 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-operand" +message = "Left operand in `<` comparison has `mixed` type." +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "mixed-operand" +message = "Left operand in `>=` comparison has `mixed` type." +count = 3 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "non-existent-class-like" +message = 'Class, Interface, or Trait `GuzzleHttp\Exception\RequestException` does not exist.' +count = 3 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "non-existent-method" +message = 'Method `getresponse` does not exist on type `GuzzleHttp\Exception\RequestException`.' +count = 1 + +[[issues]] +file = "src/Tracing/GuzzleTracingMiddleware.php" +code = "possibly-null-argument" +message = "Argument #2 of function `in_array` is possibly `null`, but parameter type `array` does not accept it." +count = 1 + +[[issues]] +file = "src/Tracing/PropagationContext.php" +code = "write-only-property" +message = "Property `$parentSampled` is written to but never read." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "invalid-type-cast" +message = "Non numeric string of type `string` implicitly cast to `float`." +count = 4 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "mixed-argument" +message = "Invalid argument type for argument #3 of `sprintf`: expected `Stringable|null|scalar`, but found `nonnull`." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 1 + +[[issues]] +file = "src/Tracing/Traits/TraceHeaderParserTrait.php" +code = "reference-to-undefined-variable" +message = "Reference created from a previously undefined variable `$matches`." +count = 1 + +[[issues]] +file = "src/Tracing/Transaction.php" +code = "incompatible-property-type" +message = 'Property `Sentry\Tracing\Transaction::$transaction` has an incompatible type declaration from docblock.' +count = 1 + +[[issues]] +file = "src/Tracing/Transaction.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Tracing\Transaction::getDynamicSamplingContext`: expected `Sentry\Tracing\DynamicSamplingContext`, but found `Sentry\Tracing\DynamicSamplingContext|null`.' +count = 1 + +[[issues]] +file = "src/Tracing/Transaction.php" +code = "nullable-return-statement" +message = 'Function `Sentry\Tracing\Transaction::getDynamicSamplingContext` is declared to return `Sentry\Tracing\DynamicSamplingContext` but possibly returns a nullable value (inferred as `Sentry\Tracing\DynamicSamplingContext|null`).' +count = 1 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "implicit-to-string-cast" +message = 'Implicit conversion to `string` for left operand via `Sentry\Severity::__toString()`.' +count = 2 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "mixed-argument" +message = "Invalid argument type for argument #2 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "mixed-argument" +message = "Invalid argument type for argument #3 of `sprintf`: expected `Stringable|null|scalar`, but found `mixed`." +count = 1 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "possible-method-access-on-null" +message = "Attempting to call a method on `null`." +count = 2 + +[[issues]] +file = "src/Transport/HttpTransport.php" +code = "possibly-null-operand" +message = 'Possibly null left operand used in string concatenation (type `Sentry\Severity|null`).' +count = 2 + +[[issues]] +file = "src/UserDataBag.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setEmail`: expected `null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setId`: expected `int|null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setIpAddress`: expected `null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-argument" +message = 'Invalid argument type for argument #1 of `Sentry\UserDataBag::setUsername`: expected `null|string`, but found `mixed`.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "no-value" +message = 'Argument #1 passed to method `Sentry\Util\DebugType::getDebugType` has type `never`, meaning it cannot produce a value.' +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/UserDataBag.php" +code = "redundant-type-comparison" +message = "Redundant type assertion: `$id` is already `int`." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "invalid-iterator" +message = "The expression provided to `foreach` is not iterable. It resolved to type `mixed`, which is not iterable." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "less-specific-argument" +message = 'Argument type mismatch for argument #1 of `Sentry\Util\Arr::isList`: expected `array`, but provided type `non-empty-array` is less specific.' +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 3 + +[[issues]] +file = "src/Util/Arr.php" +code = "mixed-operand" +message = "Invalid left operand: type `mixed` cannot be reliably used in string concatenation." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "mixed-operand" +message = "Invalid right operand: type `mixed` cannot be reliably used in string concatenation." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is always not identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Util/Arr.php" +code = "redundant-condition" +message = "This condition (type `true`) will always evaluate to true." +count = 1 + +[[issues]] +file = "src/Util/DebugType.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Util/DebugType.php" +code = "mixed-argument" +message = "Invalid argument type for argument #1 of `get_resource_type`: expected `resource`, but found `nonnull`." +count = 1 + +[[issues]] +file = "src/Util/DebugType.php" +code = "possibly-false-argument" +message = "Argument #1 of function `key` is possibly `false`, but parameter type `array|object` does not accept it." +count = 1 + +[[issues]] +file = "src/Util/DebugType.php" +code = "redundant-comparison" +message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Util/JSON.php" +code = "falsable-return-statement" +message = '''Function `Sentry\Util\JSON::encode` is declared to return `string` but possibly returns 'false' (inferred as `false|non-empty-string`).''' +count = 1 + +[[issues]] +file = "src/Util/JSON.php" +code = "invalid-return-statement" +message = 'Invalid return type for function `Sentry\Util\JSON::encode`: expected `string`, but found `false|non-empty-string`.' +count = 1 + +[[issues]] +file = "src/Util/JSON.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "avoid-catching-error" +message = "Avoid catching 'Error' class instances." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "impossible-condition" +message = "Redundant ternary operator: condition is always falsy." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is never identical to (always false for !==) right-hand side." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "redundant-comparison" +message = "Redundant `===` comparison: left-hand side is never identical to right-hand side." +count = 1 + +[[issues]] +file = "src/Util/Str.php" +code = "redundant-logical-operation" +message = "Redundant `&&` operation: left operand is evaluated and right operand is always falsy." +count = 1 + +[[issues]] +file = "src/functions.php" +code = "mixed-assignment" +message = "Assigning `mixed` type to a variable may lead to unexpected behavior." +count = 1 diff --git a/composer.json b/composer.json index a0a48782ec..c545b0bbcf 100644 --- a/composer.json +++ b/composer.json @@ -30,19 +30,21 @@ "psr/log": "^1.0|^2.0|^3.0" }, "require-dev": { + "carthage-software/mago": "1.30.0", "friendsofphp/php-cs-fixer": "^3.4", "guzzlehttp/promises": "^2.0.3", - "guzzlehttp/psr7": "^1.8.4|^2.1.1", "monolog/monolog": "^1.6|^2.0|^3.0", "nyholm/psr7": "^1.8", - "phpbench/phpbench": "^1.0", + "open-telemetry/api": "^1.0", + "open-telemetry/exporter-otlp": "^1.0", + "open-telemetry/sdk": "^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" + "spiral/roadrunner-worker": "^3.6" }, "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." }, "conflict": { @@ -65,17 +67,21 @@ "check": [ "@cs-check", "@phpstan", - "@psalm", + "@mago", "@tests" ], "tests": "vendor/bin/phpunit --verbose", "cs-check": "vendor/bin/php-cs-fixer fix --verbose --diff --dry-run", "cs-fix": "vendor/bin/php-cs-fixer fix --verbose --diff", "phpstan": "vendor/bin/phpstan analyse --memory-limit 1G", - "psalm": "vendor/bin/psalm" + "mago": "vendor/bin/mago --config=mago.toml analyze" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": false, + "tbachert/spi": false + } }, "prefer-stable": true } diff --git a/mago.toml b/mago.toml new file mode 100644 index 0000000000..f8b021c004 --- /dev/null +++ b/mago.toml @@ -0,0 +1,12 @@ +[source] +paths = ["src"] +includes = ["vendor"] +excludes = [ + "tests/resources/**", + "tests/Fixtures/**", + "src/Util/ClockMock.php", + "vendor/open-telemetry/gen-otlp-protobuf/GPBMetadata/**", +] + +[analyzer] +baseline = "analysis-baseline.toml" diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index 3ae2778659..0000000000 --- a/phpbench.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php", - "runner.retry_threshold": 2, - "runner.path": "tests/Benchmark" -} diff --git a/psalm-baseline.xml b/psalm-baseline.xml deleted file mode 100644 index 8dbbbbe277..0000000000 --- a/psalm-baseline.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - $parsedDsn['host'] - $parsedDsn['path'] - $parsedDsn['scheme'] - $parsedDsn['user'] - - - - - $userIntegration - $userIntegrations - - - - - $record['channel'] - $record['level'] - $record['level'] - $record['message'] - - - getTimestamp - - - Level|int - int|string|Level|LogLevel::* - - - - - CompatibilityLogLevelTrait - - - Level - - - - - CompatibilityProcessingHandlerTrait - - - Level - - - - - $record['channel'] - $record['level'] - $record['message'] - - - $record['context']['exception'] - - - $record['context'] - $record['context'] - - - - - $record['level'] - $record['level'] - $record['message'] - - - $record['context']['exception'] - - - $record['context'] - $record['context'] - - - - - - SentryProfile|null - - - - - (string) $value - - - - - $value - - - representationSerialize - - - - - $transaction - - - diff --git a/psalm.xml.dist b/psalm.xml.dist deleted file mode 100644 index 0584b303fd..0000000000 --- a/psalm.xml.dist +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Agent/Transport/AgentClient.php b/src/Agent/Transport/AgentClient.php new file mode 100644 index 0000000000..fdb17aba9e --- /dev/null +++ b/src/Agent/Transport/AgentClient.php @@ -0,0 +1,277 @@ +host = $host; + $this->port = $port; + $this->fallbackClientFactory = $fallbackClientFactory; + } + + public function __destruct() + { + $this->disconnect(); + } + + /** + * @phpstan-assert-if-true resource $this->socket + */ + private function connect(): bool + { + if ($this->socket !== null) { + return true; + } + + // 10ms connect timeout to avoid blocking the request if the agent is not running + $errorNo = 0; + $errorMsg = ''; + $socket = @fsockopen($this->host, $this->port, $errorNo, $errorMsg, self::SOCKET_TIMEOUT_SECONDS); + + if ($socket === false) { + $this->lastSendError = \sprintf( + 'Failed to connect to the local Sentry agent at %s:%d. [%d] %s', + $this->host, + $this->port, + $errorNo, + $errorMsg + ); + + return false; + } + + // Use non-blocking writes with stream_select() so a hung agent cannot block the caller indefinitely. + stream_set_blocking($socket, false); + + $this->socket = $socket; + + return true; + } + + private function disconnect(): void + { + if ($this->socket === null) { + return; + } + + fclose($this->socket); + + $this->socket = null; + } + + private function send(string $message): bool + { + $this->lastSendError = ''; + + $payload = pack('N', \strlen($message) + 4) . $message; + + // Attempt to send the payload, retrying once on write failure to handle + // stale sockets (e.g. agent restarts in long-running workers). + for ($attempt = 0; $attempt < 2; ++$attempt) { + if (!$this->connect()) { + return false; + } + + if ($this->writePayload($payload)) { + return true; + } + + $this->disconnect(); + } + + $this->lastSendError = \sprintf( + 'Failed to write envelope to the local Sentry agent at %s:%d.', + $this->host, + $this->port + ); + + return false; + } + + private function writePayload(string $payload): bool + { + if ($this->socket === null) { + return false; + } + + $socket = $this->socket; + $payloadLength = \strlen($payload); + $totalWrittenBytes = 0; + $writeDeadline = microtime(true) + self::SOCKET_TIMEOUT_SECONDS; + + while ($totalWrittenBytes < $payloadLength) { + if (!$this->waitUntilSocketIsWritable($socket, $writeDeadline)) { + return false; + } + + $bytesWritten = @fwrite($socket, (string) substr($payload, $totalWrittenBytes)); + + if ($bytesWritten === false) { + return false; + } + + $totalWrittenBytes += $bytesWritten; + } + + return true; + } + + /** + * @param resource $socket + */ + private function waitUntilSocketIsWritable($socket, float $deadline): bool + { + $remainingSeconds = $deadline - microtime(true); + + if ($remainingSeconds <= 0) { + return false; + } + + $readSockets = null; + $writeSockets = [$socket]; + $exceptSockets = null; + $selectedSockets = @stream_select( + $readSockets, + $writeSockets, + $exceptSockets, + 0, + (int) ceil($remainingSeconds * 1000000) + ); + + return $selectedSockets !== false && $selectedSockets > 0; + } + + private function getFallbackClient(): ?HttpClientInterface + { + if ($this->fallbackClient !== null) { + return $this->fallbackClient; + } + + if ($this->fallbackClientFactory === null) { + return null; + } + + try { + $fallbackClient = ($this->fallbackClientFactory)(); + } catch (\Throwable $exception) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = \sprintf( + 'Failed to initialize fallback HTTP client. Reason: "%s". Fallback delivery has been disabled.', + $exception->getMessage() + ); + + return null; + } + + if (!$fallbackClient instanceof HttpClientInterface) { + $this->fallbackClientFactory = null; + $this->fallbackClientError = 'The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.'; + + return null; + } + + $this->fallbackClient = $fallbackClient; + + return $this->fallbackClient; + } + + public function sendRequest(Request $request, Options $options): Response + { + $body = $request->getStringBody(); + + if (empty($body)) { + return new Response(400, [], 'Request body is empty'); + } + + if ($this->send($body)) { + // Since we are sending async there is no feedback so we always return an empty response + return new Response(202, [], ''); + } + + $logContext = [ + 'agent_host' => $this->host, + 'agent_port' => $this->port, + ]; + + if ($this->lastSendError !== '') { + $logContext['error'] = $this->lastSendError; + } + + $options->getLoggerOrNullLogger()->debug('Failed to hand off envelope to local Sentry agent.', $logContext); + + $fallbackClient = $this->getFallbackClient(); + if ($fallbackClient !== null) { + $options->getLoggerOrNullLogger()->debug('Using fallback HTTP client because local Sentry agent handoff failed.', $logContext); + + try { + return $fallbackClient->sendRequest($request, $options); + } catch (\Throwable $exception) { + $options->getLoggerOrNullLogger()->debug( + 'Fallback HTTP client failed while sending envelope.', + array_merge($logContext, ['exception' => $exception]) + ); + + return new Response(502, [], \sprintf( + 'Failed to send envelope using fallback HTTP client. Reason: "%s".', + $exception->getMessage() + )); + } + } + + if ($this->fallbackClientError !== null) { + $options->getLoggerOrNullLogger()->debug($this->fallbackClientError, $logContext); + $this->fallbackClientError = null; + } + + return new Response(502, [], 'Failed to send envelope to the local Sentry agent and no fallback client is available.'); + } +} diff --git a/src/Agent/Transport/AgentClientBuilder.php b/src/Agent/Transport/AgentClientBuilder.php new file mode 100644 index 0000000000..dcdd6bb2ae --- /dev/null +++ b/src/Agent/Transport/AgentClientBuilder.php @@ -0,0 +1,127 @@ +host = $host; + + return $this; + } + + public function setPort(int $port): self + { + $this->port = $port; + + return $this; + } + + public function disableFallbackClient(): self + { + $this->isFallbackClientDisabled = true; + $this->fallbackClientFactory = null; + + return $this; + } + + public function setFallbackClient(HttpClientInterface $fallbackClient): self + { + return $this->setFallbackClientFactory(static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + } + + /** + * @phpstan-param callable(): HttpClientInterface $fallbackClientFactory + */ + public function setFallbackClientFactory(callable $fallbackClientFactory): self + { + $this->isFallbackClientDisabled = false; + $this->fallbackClientFactory = $fallbackClientFactory; + + return $this; + } + + public function setSdkIdentifier(string $sdkIdentifier): self + { + $this->sdkIdentifier = $sdkIdentifier; + + return $this; + } + + public function setSdkVersion(string $sdkVersion): self + { + $this->sdkVersion = $sdkVersion; + + return $this; + } + + public function getClient(): AgentClient + { + if ($this->isFallbackClientDisabled) { + return new AgentClient($this->host, $this->port, null); + } + + if ($this->fallbackClientFactory !== null) { + return new AgentClient($this->host, $this->port, $this->fallbackClientFactory); + } + + return new AgentClient($this->host, $this->port, $this->createDefaultFallbackClientFactory()); + } + + /** + * @return callable(): HttpClientInterface + */ + private function createDefaultFallbackClientFactory(): callable + { + $sdkIdentifier = $this->sdkIdentifier; + $sdkVersion = $this->sdkVersion; + + return static function () use ($sdkIdentifier, $sdkVersion): HttpClientInterface { + return new HttpClient($sdkIdentifier, $sdkVersion); + }; + } +} diff --git a/src/Breadcrumb.php b/src/Breadcrumb.php index a5e3116dcd..c6d1fbf445 100644 --- a/src/Breadcrumb.php +++ b/src/Breadcrumb.php @@ -323,7 +323,7 @@ public function withTimestamp(float $timestamp): self * * @param array $data Data used to populate the breadcrumb * - * @psalm-param array{ + * @phpstan-param array{ * level: string, * type?: string, * category: string, diff --git a/src/Client.php b/src/Client.php index 23f546024a..2bb80c7307 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface /** * The version of the SDK. */ - public const SDK_VERSION = '4.22.0'; + public const SDK_VERSION = '4.27.0'; /** * Regex pattern to detect if a string is a regex pattern (starts and ends with / optionally followed by flags). @@ -58,7 +58,7 @@ class Client implements ClientInterface /** * @var array The stack of integrations * - * @psalm-var array, IntegrationInterface> + * @phpstan-var array, IntegrationInterface> */ private $integrations; @@ -224,11 +224,11 @@ public function captureLastError(?Scope $scope = null, ?EventHint $hint = null): /** * {@inheritdoc} * - * @psalm-template T of IntegrationInterface + * @phpstan-template T of IntegrationInterface */ public function getIntegration(string $className): ?IntegrationInterface { - /** @psalm-var T|null */ + /** @phpstan-var T|null */ return $this->integrations[$className] ?? null; } diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 0aab383a4d..5d511519f7 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -61,11 +61,11 @@ public function captureEvent(Event $event, ?EventHint $hint = null, ?Scope $scop * * @param string $className The FQCN of the integration * - * @psalm-template T of IntegrationInterface + * @phpstan-template T of IntegrationInterface * - * @psalm-param class-string $className + * @phpstan-param class-string $className * - * @psalm-return T|null + * @phpstan-return T|null */ public function getIntegration(string $className): ?IntegrationInterface; diff --git a/src/Dsn.php b/src/Dsn.php index e9741a44f4..0993db3e8a 100644 --- a/src/Dsn.php +++ b/src/Dsn.php @@ -192,6 +192,14 @@ public function getCspReportEndpointUrl(): string return $this->getBaseEndpointUrl() . '/security/?sentry_key=' . $this->publicKey; } + /** + * Returns the URL of the API for the OTLP traces endpoint. + */ + public function getOtlpTracesEndpointUrl(): string + { + return $this->getBaseEndpointUrl() . '/integration/otlp/v1/traces/'; + } + /** * @see https://www.php.net/manual/en/language.oop5.magic.php#object.tostring */ diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index ba44df0c68..3f3dd2d836 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -13,7 +13,7 @@ * error handler more than once is not supported and will lead to nasty * problems. The code is based on the Symfony ErrorHandler component. * - * @psalm-import-type StacktraceFrame from FrameBuilder + * @phpstan-import-type StacktraceFrame from FrameBuilder */ final class ErrorHandler { @@ -44,21 +44,21 @@ final class ErrorHandler /** * @var callable[] List of listeners that will act on each captured error * - * @psalm-var (callable(\ErrorException): void)[] + * @phpstan-var (callable(\ErrorException): void)[] */ private $errorListeners = []; /** * @var callable[] List of listeners that will act of each captured fatal error * - * @psalm-var (callable(FatalErrorException): void)[] + * @phpstan-var (callable(FatalErrorException): void)[] */ private $fatalErrorListeners = []; /** * @var callable[] List of listeners that will act on each captured exception * - * @psalm-var (callable(\Throwable): void)[] + * @phpstan-var (callable(\Throwable): void)[] */ private $exceptionListeners = []; @@ -76,7 +76,7 @@ final class ErrorHandler /** * @var callable|null The previous exception handler, if any * - * @psalm-var null|callable(\Throwable): void + * @phpstan-var (callable(\Throwable): void)|null */ private $previousExceptionHandler; @@ -250,7 +250,7 @@ public static function registerOnceExceptionHandler(): self * and that must accept a single argument * of type \ErrorException * - * @psalm-param callable(\ErrorException): void $listener + * @phpstan-param callable(\ErrorException): void $listener */ public function addErrorHandlerListener(callable $listener): void { @@ -265,7 +265,7 @@ public function addErrorHandlerListener(callable $listener): void * and that must accept a single argument * of type \Sentry\Exception\FatalErrorException * - * @psalm-param callable(FatalErrorException): void $listener + * @phpstan-param callable(FatalErrorException): void $listener */ public function addFatalErrorHandlerListener(callable $listener): void { @@ -280,7 +280,7 @@ public function addFatalErrorHandlerListener(callable $listener): void * and that must accept a single argument * of type \Throwable * - * @psalm-param callable(\Throwable): void $listener + * @phpstan-param callable(\Throwable): void $listener */ public function addExceptionHandlerListener(callable $listener): void { @@ -394,8 +394,15 @@ private function handleFatalError(): void && preg_match(self::OOM_MESSAGE_MATCHER, $error['message'], $matches) === 1 ) { $currentMemoryLimit = (int) $matches['memory_limit']; + $newMemoryLimit = $currentMemoryLimit + $this->memoryLimitIncreaseOnOutOfMemoryErrorValue; - ini_set('memory_limit', (string) ($currentMemoryLimit + $this->memoryLimitIncreaseOnOutOfMemoryErrorValue)); + // It can happen that the memory limit + increase is still lower than + // the memory that is currently being used. This produces warnings + // that may end up in Sentry. To prevent this, we can check the real + // usage before. + if ($newMemoryLimit > memory_get_usage(true)) { + $this->setMemoryLimitWithoutHandlingWarnings($newMemoryLimit); + } self::$didIncreaseMemoryLimit = true; } @@ -452,6 +459,23 @@ private function handleException(\Throwable $exception): void $this->handleException($previousExceptionHandlerException); } + /** + * Set the memory_limit while having no real error handler so that a warning emitted + * will not get reported. + */ + private function setMemoryLimitWithoutHandlingWarnings(int $memoryLimit): void + { + set_error_handler(static function (): bool { + return true; + }, \E_WARNING); + + try { + ini_set('memory_limit', (string) $memoryLimit); + } finally { + restore_error_handler(); + } + } + /** * Cleans and returns the backtrace without the first frames that belong to * this error handler. @@ -460,7 +484,7 @@ private function handleException(\Throwable $exception): void * @param string $file The filename the backtrace was raised in * @param int $line The line number the backtrace was raised at * - * @psalm-param list $backtrace + * @phpstan-param list $backtrace * * @return array */ diff --git a/src/Event.php b/src/Event.php index 93c2037423..535d9abd05 100644 --- a/src/Event.php +++ b/src/Event.php @@ -899,13 +899,13 @@ public function setSdkMetadata(string $name, $data): self /** * Gets the SDK metadata. * - * @psalm-template T of string|null + * @phpstan-template T of string|null * - * @psalm-param T $name + * @phpstan-param T $name * * @return mixed * - * @psalm-return (T is string ? mixed : array|null) + * @phpstan-return (T is string ? mixed : array|null) */ public function getSdkMetadata(?string $name = null) { diff --git a/src/EventHint.php b/src/EventHint.php index 3fa868c3e2..2160b56890 100644 --- a/src/EventHint.php +++ b/src/EventHint.php @@ -42,7 +42,7 @@ final class EventHint /** * Create a EventHint instance from an array of values. * - * @psalm-param array{ + * @phpstan-param array{ * exception?: \Throwable|null, * mechanism?: ExceptionMechanism|null, * stacktrace?: Stacktrace|null, diff --git a/src/FrameBuilder.php b/src/FrameBuilder.php index 6eae5feb99..7c8628f481 100644 --- a/src/FrameBuilder.php +++ b/src/FrameBuilder.php @@ -12,7 +12,7 @@ * * @internal * - * @psalm-type StacktraceFrame array{ + * @phpstan-type StacktraceFrame array{ * function?: string, * line?: int, * file?: string, @@ -54,7 +54,7 @@ public function __construct(Options $options, RepresentationSerializerInterface * @param int $line The line at which the frame originated * @param array $backtraceFrame The raw frame * - * @psalm-param StacktraceFrame $backtraceFrame + * @phpstan-param StacktraceFrame $backtraceFrame */ public function buildFromBacktraceFrame(string $file, int $line, array $backtraceFrame): Frame { @@ -158,7 +158,7 @@ private function isFrameInApp(string $file, ?string $functionName): bool * * @param array $backtraceFrame The frame data * - * @psalm-param StacktraceFrame $backtraceFrame + * @phpstan-param StacktraceFrame $backtraceFrame * * @return array */ diff --git a/src/Integration/FrameContextifierIntegration.php b/src/Integration/FrameContextifierIntegration.php index a49693d588..8e55a97278 100644 --- a/src/Integration/FrameContextifierIntegration.php +++ b/src/Integration/FrameContextifierIntegration.php @@ -110,7 +110,7 @@ private function addContextToStacktraceFrame(int $maxContextLines, Frame $frame) * * @return array * - * @psalm-return array{ + * @phpstan-return array{ * pre_context: string[], * context_line: string|null, * post_context: string[] diff --git a/src/Integration/OTLPIntegration.php b/src/Integration/OTLPIntegration.php new file mode 100644 index 0000000000..e5e04ef8e2 --- /dev/null +++ b/src/Integration/OTLPIntegration.php @@ -0,0 +1,201 @@ +setupOtlpTracesExporter = $setupOtlpTracesExporter; + $this->collectorUrl = $collectorUrl; + } + + public function setOptions(Options $options): void + { + $this->options = $options; + } + + public function setupOnce(): void + { + $options = $this->options; + + if ($options === null) { + $this->logDebug('Skipping OTLPIntegration setup because client options were not provided.'); + + return; + } + + if ($options->isTracingEnabled()) { + $this->logDebug('Skipping OTLPIntegration because Sentry tracing is enabled. Disable "traces_sample_rate", "traces_sampler", and "enable_tracing" before using OTLPIntegration.'); + + return; + } + + Scope::registerExternalPropagationContext(static function (): ?array { + $currentHub = SentrySdk::getCurrentHub(); + $integration = $currentHub->getIntegration(self::class); + + if (!$integration instanceof self) { + return null; + } + + return $integration->getCurrentOpenTelemetryPropagationContext(); + }); + + if ($this->setupOtlpTracesExporter) { + $this->configureOtlpTracesExporter($options); + } + } + + public function getCollectorUrl(): ?string + { + return $this->collectorUrl; + } + + /** + * @return array{trace_id: string, span_id: string}|null + */ + private function getCurrentOpenTelemetryPropagationContext(): ?array + { + if (!class_exists(\OpenTelemetry\API\Trace\Span::class)) { + return null; + } + + $spanContext = \OpenTelemetry\API\Trace\Span::getCurrent()->getContext(); + + if (!$spanContext->isValid()) { + return null; + } + + return [ + 'trace_id' => $spanContext->getTraceId(), + 'span_id' => $spanContext->getSpanId(), + ]; + } + + private function configureOtlpTracesExporter(Options $options): void + { + $endpoint = $this->collectorUrl; + $headers = []; + $dsn = $options->getDsn(); + + if ($endpoint === null && $dsn !== null) { + $endpoint = $dsn->getOtlpTracesEndpointUrl(); + $headers['X-Sentry-Auth'] = Http::getSentryAuthHeader($dsn, Client::SDK_IDENTIFIER, Client::SDK_VERSION); + } + + if ($endpoint === null) { + $this->logDebug('Skipping automatic OTLP exporter setup because neither a DSN nor a collector URL is configured.'); + + return; + } + + if (!$this->shouldConfigureOtlpTracesExporter()) { + return; + } + + try { + $transport = (new \OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory())->create( + $endpoint, + \OpenTelemetry\Contrib\Otlp\ContentTypes::PROTOBUF, + $headers + ); + $spanExporter = new \OpenTelemetry\Contrib\Otlp\SpanExporter($transport); + $batchSpanProcessor = new \OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor( + $spanExporter, + \OpenTelemetry\API\Common\Time\Clock::getDefault() + ); + + (new \OpenTelemetry\SDK\SdkBuilder()) + ->setTracerProvider(new \OpenTelemetry\SDK\Trace\TracerProvider($batchSpanProcessor)) + ->buildAndRegisterGlobal(); + } catch (\Throwable $exception) { + $this->logDebug(\sprintf('Skipping automatic OTLP exporter setup because it could not be configured: %s', $exception->getMessage())); + } + } + + private function shouldConfigureOtlpTracesExporter(): bool + { + if (\PHP_VERSION_ID < 80100) { + $this->logDebug('Skipping automatic OTLP exporter setup because it requires PHP 8.1 or newer.'); + + return false; + } + + foreach ([ + \OpenTelemetry\API\Globals::class, + \OpenTelemetry\API\Common\Time\Clock::class, + \OpenTelemetry\SDK\SdkBuilder::class, + \OpenTelemetry\SDK\Trace\TracerProvider::class, + \OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor::class, + \OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory::class, + \OpenTelemetry\Contrib\Otlp\SpanExporter::class, + ] as $className) { + if (!class_exists($className)) { + $this->logDebug('Skipping automatic OTLP exporter setup because the required OpenTelemetry SDK/exporter classes are not available.'); + + return false; + } + } + + try { + if (!$this->isNoopTracerProvider(\OpenTelemetry\API\Globals::tracerProvider())) { + $this->logDebug('Skipping automatic OTLP exporter setup because the existing OpenTelemetry tracer provider cannot be modified after construction.'); + + return false; + } + } catch (\Throwable $exception) { + $this->logDebug(\sprintf('Skipping automatic OTLP exporter setup because the current OpenTelemetry tracer provider could not be inspected: %s', $exception->getMessage())); + + return false; + } + + return true; + } + + private function isNoopTracerProvider(?object $tracerProvider): bool + { + return $tracerProvider === null || $tracerProvider instanceof \OpenTelemetry\API\Trace\NoopTracerProvider; + } + + private function logDebug(string $message): void + { + $this->getLogger()->debug($message); + } + + private function getLogger(): LoggerInterface + { + if ($this->options !== null) { + return $this->options->getLoggerOrNullLogger(); + } + + $currentHub = SentrySdk::getCurrentHub(); + $client = $currentHub->getClient(); + + return $client->getOptions()->getLoggerOrNullLogger(); + } +} diff --git a/src/Integration/RequestIntegration.php b/src/Integration/RequestIntegration.php index 431d7fccf2..db123b0198 100644 --- a/src/Integration/RequestIntegration.php +++ b/src/Integration/RequestIntegration.php @@ -67,6 +67,10 @@ final class RequestIntegration implements IntegrationInterface /** * @var array The options + * + * @phpstan-var array{ + * pii_sanitize_headers: string[] + * } */ private $options; @@ -75,6 +79,10 @@ final class RequestIntegration implements IntegrationInterface * * @param RequestFetcherInterface|null $requestFetcher PSR-7 request fetcher * @param array $options The options + * + * @phpstan-param array{ + * pii_sanitize_headers?: string[] + * } $options */ public function __construct(?RequestFetcherInterface $requestFetcher = null, array $options = []) { @@ -83,7 +91,10 @@ public function __construct(?RequestFetcherInterface $requestFetcher = null, arr $this->configureOptions($resolver); $this->requestFetcher = $requestFetcher ?? new RequestFetcher(); - $this->options = $resolver->resolve($options); + + /** @var array{pii_sanitize_headers: string[]} $resolvedOptions */ + $resolvedOptions = $resolver->resolve($options); + $this->options = $resolvedOptions; } /** diff --git a/src/Logger/DebugLogger.php b/src/Logger/DebugLogger.php index 7ebe021153..9b7a27583e 100644 --- a/src/Logger/DebugLogger.php +++ b/src/Logger/DebugLogger.php @@ -9,9 +9,9 @@ abstract class DebugLogger extends AbstractLogger { /** - * @param mixed $level - * @param string|\Stringable $message - * @param mixed[] $context + * @param mixed $level + * @param string $message + * @param mixed[] $context */ public function log($level, $message, array $context = []): void { diff --git a/src/Logs/LogsAggregator.php b/src/Logs/LogsAggregator.php index 5413cbf797..0fa66b7fcb 100644 --- a/src/Logs/LogsAggregator.php +++ b/src/Logs/LogsAggregator.php @@ -13,16 +13,19 @@ use Sentry\State\Scope; use Sentry\Util\Arr; use Sentry\Util\Str; +use Sentry\Util\TelemetryStorage; /** * @internal */ final class LogsAggregator { + private const LOGS_BUFFER_SIZE = 1000; + /** - * @var Log[] + * @var TelemetryStorage|null */ - private $logs = []; + private $logs; /** * @param string $message see sprintf for a description of format @@ -67,11 +70,15 @@ public function add( $formattedMessage = $message; } - $log = (new Log($timestamp, $this->getTraceId($hub), $level, $formattedMessage)) + $traceData = $this->getTraceData($hub); + $traceId = $traceData['trace_id']; + $parentSpanId = $traceData['parent_span_id']; + + $log = (new Log($timestamp, $traceId, $level, $formattedMessage)) ->setAttribute('sentry.release', $options->getRelease()) ->setAttribute('sentry.environment', $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT) - ->setAttribute('sentry.server.address', $options->getServerName()) - ->setAttribute('sentry.trace.parent_span_id', $hub->getSpan() ? $hub->getSpan()->getSpanId() : null); + ->setAttribute('server.address', $options->getServerName()) + ->setAttribute('sentry.trace.parent_span_id', $parentSpanId); if ($client instanceof Client) { $log->setAttribute('sentry.sdk.name', $client->getSdkIdentifier()); @@ -146,19 +153,24 @@ public function add( $sdkLogger->log($log->getPsrLevel(), "Logs item: {$log->getBody()}", $log->attributes()->toSimpleArray()); } - $this->logs[] = $log; + $logFlushThreshold = $options->getLogFlushThreshold(); + $logs = $this->getStorage($logFlushThreshold); + + $logs->push($log); + + if ($logFlushThreshold !== null && \count($logs) >= $logFlushThreshold) { + $this->flush($hub); + } } public function flush(?HubInterface $hub = null): ?EventId { - if (empty($this->logs)) { + if ($this->logs === null || $this->logs->isEmpty()) { return null; } $hub = $hub ?? SentrySdk::getCurrentHub(); - $event = Event::createLogs()->setLogs($this->logs); - - $this->logs = []; + $event = Event::createLogs()->setLogs($this->logs->drain()); return $hub->captureEvent($event); } @@ -168,23 +180,61 @@ public function flush(?HubInterface $hub = null): ?EventId */ public function all(): array { - return $this->logs; + return $this->logs !== null ? $this->logs->toArray() : []; } - private function getTraceId(HubInterface $hub): string + /** + * @return array{trace_id: string, parent_span_id: string|null} + */ + private function getTraceData(HubInterface $hub): array { $span = $hub->getSpan(); if ($span !== null) { - return (string) $span->getTraceId(); + return [ + 'trace_id' => (string) $span->getTraceId(), + 'parent_span_id' => (string) $span->getSpanId(), + ]; } - $traceId = ''; + $traceData = null; + + $hub->configureScope(static function (Scope $scope) use (&$traceData): void { + $externalPropagationContext = Scope::getExternalPropagationContext(); + + if ($externalPropagationContext !== null) { + $traceData = [ + 'trace_id' => $externalPropagationContext['trace_id'], + 'parent_span_id' => $externalPropagationContext['span_id'], + ]; - $hub->configureScope(static function (Scope $scope) use (&$traceId) { - $traceId = (string) $scope->getPropagationContext()->getTraceId(); + return; + } + + $traceData = [ + 'trace_id' => (string) $scope->getPropagationContext()->getTraceId(), + 'parent_span_id' => null, + ]; }); - return $traceId; + /** @var array{trace_id: string, parent_span_id: string|null} $traceData */ + return $traceData; + } + + /** + * @return TelemetryStorage + */ + private function getStorage(?int $logFlushThreshold = null): TelemetryStorage + { + if ($this->logs === null) { + /** @var TelemetryStorage $logs */ + $logs = $logFlushThreshold !== null + ? TelemetryStorage::unbounded() + : TelemetryStorage::bounded(self::LOGS_BUFFER_SIZE); + + $this->logs = $logs; + } + + return $this->logs; } } diff --git a/src/Metrics/MetricsAggregator.php b/src/Metrics/MetricsAggregator.php index 46f015be0e..a4a3aef3e4 100644 --- a/src/Metrics/MetricsAggregator.php +++ b/src/Metrics/MetricsAggregator.php @@ -14,8 +14,10 @@ use Sentry\SentrySdk; use Sentry\State\HubInterface; use Sentry\State\Scope; +use Sentry\Tracing\SpanId; +use Sentry\Tracing\TraceId; use Sentry\Unit; -use Sentry\Util\RingBuffer; +use Sentry\Util\TelemetryStorage; /** * @internal @@ -27,22 +29,17 @@ final class MetricsAggregator */ public const METRICS_BUFFER_SIZE = 1000; - /** - * @var RingBuffer - */ - private $metrics; - - public function __construct() - { - $this->metrics = new RingBuffer(self::METRICS_BUFFER_SIZE); - } - private const METRIC_TYPES = [ CounterMetric::TYPE => CounterMetric::class, DistributionMetric::TYPE => DistributionMetric::class, GaugeMetric::TYPE => GaugeMetric::class, ]; + /** + * @var TelemetryStorage|null + */ + private $metrics; + /** * @param int|float $value * @param array $attributes @@ -56,6 +53,7 @@ public function add( ): void { $hub = SentrySdk::getCurrentHub(); $client = $hub->getClient(); + $metricFlushThreshold = null; if (!\is_int($value) && !\is_float($value)) { if ($client !== null) { @@ -65,37 +63,39 @@ public function add( return; } - if ($client instanceof Client) { + if ($client !== null) { $options = $client->getOptions(); + $metricFlushThreshold = $options->getMetricFlushThreshold(); if ($options->getEnableMetrics() === false) { return; } $defaultAttributes = [ - 'sentry.sdk.name' => $client->getSdkIdentifier(), - 'sentry.sdk.version' => $client->getSdkVersion(), 'sentry.environment' => $options->getEnvironment() ?? Event::DEFAULT_ENVIRONMENT, 'server.address' => $options->getServerName(), ]; - if ($options->shouldSendDefaultPii()) { - $hub->configureScope(static function (Scope $scope) use (&$defaultAttributes) { - $user = $scope->getUser(); - if ($user !== null) { - if ($user->getId() !== null) { - $defaultAttributes['user.id'] = $user->getId(); - } - if ($user->getEmail() !== null) { - $defaultAttributes['user.email'] = $user->getEmail(); - } - if ($user->getUsername() !== null) { - $defaultAttributes['user.name'] = $user->getUsername(); - } - } - }); + if ($client instanceof Client) { + $defaultAttributes['sentry.sdk.name'] = $client->getSdkIdentifier(); + $defaultAttributes['sentry.sdk.version'] = $client->getSdkVersion(); } + $hub->configureScope(static function (Scope $scope) use (&$defaultAttributes) { + $user = $scope->getUser(); + if ($user !== null) { + if ($user->getId() !== null) { + $defaultAttributes['user.id'] = $user->getId(); + } + if ($user->getEmail() !== null) { + $defaultAttributes['user.email'] = $user->getEmail(); + } + if ($user->getUsername() !== null) { + $defaultAttributes['user.name'] = $user->getUsername(); + } + } + }); + $release = $options->getRelease(); if ($release !== null) { $defaultAttributes['sentry.release'] = $release; @@ -104,24 +104,12 @@ public function add( $attributes += $defaultAttributes; } - $spanId = null; - $traceId = null; - - $span = $hub->getSpan(); - if ($span !== null) { - $spanId = $span->getSpanId(); - $traceId = $span->getTraceId(); - } else { - $hub->configureScope(static function (Scope $scope) use (&$traceId, &$spanId) { - $propagationContext = $scope->getPropagationContext(); - $traceId = $propagationContext->getTraceId(); - $spanId = $propagationContext->getSpanId(); - }); - } + $traceContext = $this->getTraceContext($hub); + $traceId = new TraceId($traceContext['trace_id']); + $spanId = new SpanId($traceContext['span_id']); $metricTypeClass = self::METRIC_TYPES[$type]; /** @var Metric $metric */ - /** @phpstan-ignore-next-line */ $metric = new $metricTypeClass($name, $value, $traceId, $spanId, $attributes, microtime(true), $unit); if ($client !== null) { @@ -132,12 +120,17 @@ public function add( } } - $this->metrics->push($metric); + $metrics = $this->getStorage($metricFlushThreshold); + $metrics->push($metric); + + if ($metricFlushThreshold !== null && \count($metrics) >= $metricFlushThreshold) { + $this->flush($hub); + } } public function flush(?HubInterface $hub = null): ?EventId { - if ($this->metrics->isEmpty()) { + if ($this->metrics === null || $this->metrics->isEmpty()) { return null; } @@ -146,4 +139,36 @@ public function flush(?HubInterface $hub = null): ?EventId return $hub->captureEvent($event); } + + /** + * @return array{trace_id: string, span_id: string} + */ + private function getTraceContext(HubInterface $hub): array + { + $traceContext = null; + + $hub->configureScope(static function (Scope $scope) use (&$traceContext): void { + $traceContext = $scope->getTraceContext(); + }); + + /** @var array{trace_id: string, span_id: string} $traceContext */ + return $traceContext; + } + + /** + * @return TelemetryStorage + */ + private function getStorage(?int $metricFlushThreshold = null): TelemetryStorage + { + if ($this->metrics === null) { + /** @var TelemetryStorage $metrics */ + $metrics = $metricFlushThreshold !== null + ? TelemetryStorage::unbounded() + : TelemetryStorage::bounded(self::METRICS_BUFFER_SIZE); + + $this->metrics = $metrics; + } + + return $this->metrics; + } } diff --git a/src/Monolog/BreadcrumbHandler.php b/src/Monolog/BreadcrumbHandler.php index fbda73c26d..29e166671b 100644 --- a/src/Monolog/BreadcrumbHandler.php +++ b/src/Monolog/BreadcrumbHandler.php @@ -42,8 +42,6 @@ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bub } /** - * @psalm-suppress MoreSpecificImplementedParamType - * * @param LogRecord|array{ * level: int, * channel: string, diff --git a/src/Monolog/ExceptionToSentryIssueHandler.php b/src/Monolog/ExceptionToSentryIssueHandler.php new file mode 100644 index 0000000000..b2e7abeb6b --- /dev/null +++ b/src/Monolog/ExceptionToSentryIssueHandler.php @@ -0,0 +1,129 @@ +|value-of|Level|LogLevel::* $level + */ + public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true) + { + $this->hub = $hub; + + parent::__construct($level, $bubble); + } + + /** + * @param array|LogRecord $record + */ + public function handle($record): bool + { + $exception = $this->getExceptionFromRecord($record); + + /** @phpstan-ignore-next-line */ + if ($exception === null || !$this->isHandling($record)) { + return false; + } + + $this->hub->withScope(function (Scope $scope) use ($record, $exception): void { + $scope->setExtra('monolog.channel', $record['channel']); + $scope->setExtra('monolog.level', $record['level_name']); + $scope->setExtra('monolog.message', $record['message']); + + $monologContextData = $this->getMonologContextData($this->getContextFromRecord($record)); + + if ($monologContextData !== []) { + $scope->setExtra('monolog.context', $monologContextData); + } + + $monologExtraData = $this->getExtraFromRecord($record); + + if ($monologExtraData !== []) { + $scope->setExtra('monolog.extra', $monologExtraData); + } + + $this->hub->captureException($exception); + }); + + return $this->bubble === false; + } + + /** + * @param array|LogRecord $record + */ + private function getExceptionFromRecord($record): ?\Throwable + { + $exception = $this->getContextFromRecord($record)['exception'] ?? null; + + if ($exception instanceof \Throwable) { + return $exception; + } + + return null; + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getContextFromRecord($record): array + { + return $this->getArrayFieldFromRecord($record, 'context'); + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getExtraFromRecord($record): array + { + return $this->getArrayFieldFromRecord($record, 'extra'); + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getArrayFieldFromRecord($record, string $field): array + { + if (isset($record[$field]) && \is_array($record[$field])) { + return $record[$field]; + } + + return []; + } + + /** + * @param array $context + * + * @return array + */ + private function getMonologContextData(array $context): array + { + unset($context['exception']); + + return $context; + } +} diff --git a/src/Monolog/Handler.php b/src/Monolog/Handler.php index 3e8d52bba1..6cc69fef7a 100644 --- a/src/Monolog/Handler.php +++ b/src/Monolog/Handler.php @@ -16,6 +16,11 @@ * This Monolog handler logs every message to a Sentry's server using the given * hub instance. * + * @deprecated since version 4.24. To be removed in version 5.0. Use {@see LogsHandler} + * with the `enable_logs` SDK option for Sentry logs, {@see ExceptionToSentryIssueHandler} + * to send Monolog exceptions to Sentry issues, and {@see LogToSentryIssueHandler} + * to send Monolog log messages to Sentry issues. + * * @author Stefano Arlandini */ final class Handler extends AbstractProcessingHandler diff --git a/src/Monolog/LogToSentryIssueHandler.php b/src/Monolog/LogToSentryIssueHandler.php new file mode 100644 index 0000000000..18dd6eab61 --- /dev/null +++ b/src/Monolog/LogToSentryIssueHandler.php @@ -0,0 +1,118 @@ +|value-of|Level|LogLevel::* $level + */ + public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true, bool $fillExtraContext = false) + { + $this->hub = $hub; + $this->fillExtraContext = $fillExtraContext; + + parent::__construct($level, $bubble); + } + + /** + * @param array|LogRecord $record + */ + public function handle($record): bool + { + /** @phpstan-ignore-next-line */ + if (!$this->isHandling($record) || $this->hasThrowable($record)) { + return false; + } + + /** @phpstan-ignore-next-line */ + return parent::handle($record); + } + + /** + * @param array|LogRecord $record + */ + protected function doWrite($record): void + { + $event = Event::createEvent(); + $event->setLevel(self::getSeverityFromLevel($record['level'])); + $event->setMessage($record['message']); + $event->setLogger(\sprintf('monolog.%s', $record['channel'])); + + $hint = new EventHint(); + + $this->hub->withScope(function (Scope $scope) use ($record, $event, $hint): void { + $scope->setExtra('monolog.channel', $record['channel']); + $scope->setExtra('monolog.level', $record['level_name']); + + if ($this->fillExtraContext) { + $monologContextData = $this->getArrayFieldFromRecord($record, 'context'); + + if ($monologContextData !== []) { + $scope->setExtra('monolog.context', $monologContextData); + } + + $monologExtraData = $this->getArrayFieldFromRecord($record, 'extra'); + + if ($monologExtraData !== []) { + $scope->setExtra('monolog.extra', $monologExtraData); + } + } + + $this->hub->captureEvent($event, $hint); + }); + } + + /** + * @param array|LogRecord $record + */ + private function hasThrowable($record): bool + { + $exception = $this->getArrayFieldFromRecord($record, 'context')[self::CONTEXT_EXCEPTION_KEY] ?? null; + + return $exception instanceof \Throwable; + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getArrayFieldFromRecord($record, string $field): array + { + if (isset($record[$field]) && \is_array($record[$field])) { + return $record[$field]; + } + + return []; + } +} diff --git a/src/Monolog/LogsHandler.php b/src/Monolog/LogsHandler.php index 9eb49e440d..94aa16ab28 100644 --- a/src/Monolog/LogsHandler.php +++ b/src/Monolog/LogsHandler.php @@ -18,8 +18,6 @@ class LogsHandler implements HandlerInterface /** * The minimum logging level at which this handler will be triggered. * - * @psalm-suppress UndefinedDocblockClass - * * @var LogLevel|\Monolog\Level|int */ private $logLevel; @@ -34,8 +32,6 @@ class LogsHandler implements HandlerInterface /** * Creates a new Monolog handler that converts Monolog logs to Sentry logs. * - * @psalm-suppress UndefinedDocblockClass - * * @param LogLevel|\Monolog\Level|int|null $logLevel the minimum logging level at which this handler will be triggered and collects the logs * @param bool $bubble whether the messages that are handled can bubble up the stack or not */ @@ -46,9 +42,6 @@ public function __construct($logLevel = null, bool $bubble = true) } /** - * @psalm-suppress UndefinedDocblockClass - * @psalm-suppress UndefinedClass - * * @param array|LogRecord $record */ public function isHandling($record): bool @@ -70,7 +63,7 @@ public function handle($record): bool if (!$this->isHandling($record)) { return false; } - // Do not collect logs for exceptions, they should be handled seperately by the `Handler` or `captureException` + // Do not collect logs for exceptions, they should be handled separately by `ExceptionToSentryIssueHandler` or `captureException` if (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) { return false; } diff --git a/src/Options.php b/src/Options.php index 1ed6f0144e..13bf3c0a6d 100644 --- a/src/Options.php +++ b/src/Options.php @@ -131,6 +131,50 @@ public function getEnableLogs(): bool return $this->options['enable_logs'] ?? false; } + /** + * Gets the number of buffered logs that trigger an immediate flush. + */ + public function getLogFlushThreshold(): ?int + { + /** + * @var int|null $logFlushThreshold + */ + $logFlushThreshold = $this->options['log_flush_threshold']; + + return $logFlushThreshold; + } + + /** + * Sets the number of buffered logs that trigger an immediate flush. + * null will never trigger an immediate flush. + */ + public function setLogFlushThreshold(?int $logFlushThreshold): self + { + return $this->updateOptions(['log_flush_threshold' => $logFlushThreshold]); + } + + /** + * Gets the number of buffered metrics that trigger an immediate flush. + */ + public function getMetricFlushThreshold(): ?int + { + /** + * @var int|null $metricFlushThreshold + */ + $metricFlushThreshold = $this->options['metric_flush_threshold']; + + return $metricFlushThreshold; + } + + /** + * Sets the number of buffered metrics that trigger an immediate flush. + * null will never trigger an immediate flush. + */ + public function setMetricFlushThreshold(?int $metricFlushThreshold): self + { + return $this->updateOptions(['metric_flush_threshold' => $metricFlushThreshold]); + } + /** * Sets if metrics should be enabled or not. */ @@ -176,6 +220,32 @@ public function setProfilesSampleRate(?float $sampleRate): self return $this->updateOptions(['profiles_sample_rate' => $sampleRate]); } + /** + * Gets a callback that will be invoked when we sample a profile. + * + * @phpstan-return null|callable(Tracing\SamplingContext): float + */ + public function getProfilesSampler(): ?callable + { + /** @var callable(Tracing\SamplingContext): float|null $value */ + $value = $this->options['profiles_sampler']; + + return $value; + } + + /** + * Sets a callback that will be invoked when we take the profiling sampling decision. + * Return a number between 0 and 1 to define the sample rate for the provided SamplingContext. + * + * @param ?callable $sampler The sampler + * + * @phpstan-param null|callable(Tracing\SamplingContext): float $sampler + */ + public function setProfilesSampler(?callable $sampler): self + { + return $this->updateOptions(['profiles_sampler' => $sampler]); + } + /** * Gets whether tracing is enabled or not. The feature is enabled when at * least one of the `traces_sample_rate` and `traces_sampler` options is @@ -403,7 +473,7 @@ public function setServerName(string $serverName): self * * @return string[] * - * @psalm-return list> + * @phpstan-return list> */ public function getIgnoreExceptions(): array { @@ -444,7 +514,7 @@ public function setIgnoreTransactions(array $ignoreTransaction): self * Gets a callback that will be invoked before an event is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Event, ?EventHint): ?Event + * @phpstan-return callable(Event, ?EventHint): ?Event */ public function getBeforeSendCallback(): callable { @@ -457,7 +527,7 @@ public function getBeforeSendCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Event, ?EventHint): ?Event $callback + * @phpstan-param callable(Event, ?EventHint): ?Event $callback */ public function setBeforeSendCallback(callable $callback): self { @@ -468,7 +538,7 @@ public function setBeforeSendCallback(callable $callback): self * Gets a callback that will be invoked before an transaction is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Event, ?EventHint): ?Event + * @phpstan-return callable(Event, ?EventHint): ?Event */ public function getBeforeSendTransactionCallback(): callable { @@ -481,7 +551,7 @@ public function getBeforeSendTransactionCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Event, ?EventHint): ?Event $callback + * @phpstan-param callable(Event, ?EventHint): ?Event $callback */ public function setBeforeSendTransactionCallback(callable $callback): self { @@ -492,7 +562,7 @@ public function setBeforeSendTransactionCallback(callable $callback): self * Gets a callback that will be invoked before a check-in is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Event, ?EventHint): ?Event + * @phpstan-return callable(Event, ?EventHint): ?Event */ public function getBeforeSendCheckInCallback(): callable { @@ -505,7 +575,7 @@ public function getBeforeSendCheckInCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Event, ?EventHint): ?Event $callback + * @phpstan-param callable(Event, ?EventHint): ?Event $callback */ public function setBeforeSendCheckInCallback(callable $callback): self { @@ -516,7 +586,7 @@ public function setBeforeSendCheckInCallback(callable $callback): self * Gets a callback that will be invoked before an log is sent to the server. * If `null` is returned it won't be sent. * - * @psalm-return callable(Log): ?Log + * @phpstan-return callable(Log): ?Log */ public function getBeforeSendLogCallback(): callable { @@ -529,7 +599,7 @@ public function getBeforeSendLogCallback(): callable * * @param callable $callback The callable * - * @psalm-param callable(Log): ?Log $callback + * @phpstan-param callable(Log): ?Log $callback */ public function setBeforeSendLogCallback(callable $callback): self { @@ -558,9 +628,7 @@ public function getBeforeSendMetricCallback(): callable */ public function setBeforeSendMetricCallback(callable $callback): self { - $options = array_merge($this->options, ['before_send_metric' => $callback]); - - $this->options = $this->resolver->resolve($options); + $this->updateOptions(['before_send_metric' => $callback]); return $this; } @@ -665,7 +733,7 @@ public function setMaxBreadcrumbs(int $maxBreadcrumbs): self /** * Gets a callback that will be invoked when adding a breadcrumb. * - * @psalm-return callable(Breadcrumb): ?Breadcrumb + * @phpstan-return callable(Breadcrumb): ?Breadcrumb */ public function getBeforeBreadcrumbCallback(): callable { @@ -681,7 +749,7 @@ public function getBeforeBreadcrumbCallback(): callable * * @param callable $callback The callback * - * @psalm-param callable(Breadcrumb): ?Breadcrumb $callback + * @phpstan-param callable(Breadcrumb): ?Breadcrumb $callback */ public function setBeforeBreadcrumbCallback(callable $callback): self { @@ -975,7 +1043,7 @@ public function setClassSerializers(array $serializers): self /** * Gets a callback that will be invoked when we sample a Transaction. * - * @psalm-return null|callable(Tracing\SamplingContext): float + * @phpstan-return null|callable(Tracing\SamplingContext): float */ public function getTracesSampler(): ?callable { @@ -988,7 +1056,7 @@ public function getTracesSampler(): ?callable * * @param ?callable $sampler The sampler * - * @psalm-param null|callable(Tracing\SamplingContext): float $sampler + * @phpstan-param null|callable(Tracing\SamplingContext): float $sampler */ public function setTracesSampler(?callable $sampler): self { @@ -1005,10 +1073,13 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('prefixes', 'string[]'); $resolver->setAllowedTypes('sample_rate', ['int', 'float']); $resolver->setAllowedTypes('enable_logs', 'bool'); + $resolver->setAllowedTypes('log_flush_threshold', ['null', 'int']); $resolver->setAllowedTypes('enable_metrics', 'bool'); + $resolver->setAllowedTypes('metric_flush_threshold', ['null', 'int']); $resolver->setAllowedTypes('traces_sample_rate', ['null', 'int', 'float']); $resolver->setAllowedTypes('traces_sampler', ['null', 'callable']); $resolver->setAllowedTypes('profiles_sample_rate', ['null', 'int', 'float']); + $resolver->setAllowedTypes('profiles_sampler', ['null', 'callable']); $resolver->setAllowedTypes('attach_stacktrace', 'bool'); $resolver->setAllowedTypes('context_lines', ['null', 'int']); $resolver->setAllowedTypes('environment', ['null', 'string']); @@ -1055,6 +1126,8 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedValues('max_breadcrumbs', \Closure::fromCallable([$this, 'validateMaxBreadcrumbsOptions'])); $resolver->setAllowedValues('class_serializers', \Closure::fromCallable([$this, 'validateClassSerializersOption'])); $resolver->setAllowedValues('context_lines', \Closure::fromCallable([$this, 'validateContextLinesOption'])); + $resolver->setAllowedValues('log_flush_threshold', \Closure::fromCallable([$this, 'validateLogFlushThresholdOption'])); + $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); @@ -1078,10 +1151,13 @@ private function configureOptions(OptionsResolver $resolver): void 'prefixes' => array_filter(explode(\PATH_SEPARATOR, get_include_path() ?: '')), 'sample_rate' => 1, 'enable_logs' => false, + 'log_flush_threshold' => null, 'enable_metrics' => true, + 'metric_flush_threshold' => null, 'traces_sample_rate' => null, 'traces_sampler' => null, 'profiles_sample_rate' => null, + 'profiles_sampler' => null, 'attach_stacktrace' => false, 'context_lines' => 5, 'environment' => $_SERVER['SENTRY_ENVIRONMENT'] ?? null, @@ -1283,6 +1359,26 @@ private function validateContextLinesOption(?int $contextLines): bool return $contextLines === null || $contextLines >= 0; } + /** + * Validates that the value passed to the "log_flush_threshold" option is valid. + * + * @param int|null $logFlushThreshold The value to validate + */ + private function validateLogFlushThresholdOption(?int $logFlushThreshold): bool + { + return $logFlushThreshold === null || $logFlushThreshold > 0; + } + + /** + * Validates that the value passed to the "metric_flush_threshold" option is valid. + * + * @param int|null $metricFlushThreshold The value to validate + */ + private function validateMetricFlushThresholdOption(?int $metricFlushThreshold): bool + { + return $metricFlushThreshold === null || $metricFlushThreshold > 0; + } + /** * Merges the passed options with the current options and resolves them. * The result is stored back onto the class field. diff --git a/src/SentrySdk.php b/src/SentrySdk.php index 0ef0e5270c..9adce8c282 100644 --- a/src/SentrySdk.php +++ b/src/SentrySdk.php @@ -47,7 +47,7 @@ public static function init(?ClientInterface $client = null): HubInterface self::$currentHub = new Hub($client); self::$runtimeContextManager = new RuntimeContextManager(self::$currentHub); - return self::$currentHub; + return self::getCurrentHub(); } /** @@ -97,13 +97,13 @@ public static function endContext(?int $timeout = null): void * * @param callable $callback The callback to execute * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(): T $callback + * @phpstan-param callable(): T $callback * * @return mixed * - * @psalm-return T + * @phpstan-return T */ public static function withContext(callable $callback, ?int $timeout = null) { @@ -149,10 +149,7 @@ public static function flush(): void TraceMetrics::getInstance()->flush(); $client = self::getCurrentHub()->getClient(); - - if ($client !== null) { - $client->flush(); - } + $client->flush(); } private static function getRuntimeContextManager(): RuntimeContextManager diff --git a/src/Serializer/AbstractSerializer.php b/src/Serializer/AbstractSerializer.php index 803d898b45..1f18c456c6 100644 --- a/src/Serializer/AbstractSerializer.php +++ b/src/Serializer/AbstractSerializer.php @@ -139,6 +139,10 @@ protected function serializeRecursively($value, int $_depth = 0) return $this->formatDate($value); } + if ($value instanceof \UnitEnum) { + return $this->serializeValue($value); + } + if ($this->serializeAllObjects || ($value instanceof \stdClass)) { return $this->serializeObject($value, $_depth); } @@ -243,8 +247,13 @@ protected function serializeValue($value) if ($value instanceof \UnitEnum) { $reflection = new \ReflectionObject($value); + $enumValue = $reflection->getName() . '::' . $value->name; + + if ($value instanceof \BackedEnum) { + return 'Enum ' . $enumValue . '(' . $value->value . ')'; + } - return 'Enum ' . $reflection->getName() . '::' . $value->name; + return 'Enum ' . $enumValue; } if (\is_object($value)) { diff --git a/src/Serializer/EnvelopItems/EventItem.php b/src/Serializer/EnvelopItems/EventItem.php index a656686d7b..bf44d6f894 100644 --- a/src/Serializer/EnvelopItems/EventItem.php +++ b/src/Serializer/EnvelopItems/EventItem.php @@ -147,7 +147,7 @@ public static function toEnvelopeItem(Event $event): string /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * type: string, * value: string, * stacktrace?: array{ diff --git a/src/Serializer/EnvelopItems/TransactionItem.php b/src/Serializer/EnvelopItems/TransactionItem.php index 865d284999..5ed5a54e8b 100644 --- a/src/Serializer/EnvelopItems/TransactionItem.php +++ b/src/Serializer/EnvelopItems/TransactionItem.php @@ -133,7 +133,7 @@ public static function toEnvelopeItem(Event $event): string /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * span_id: string, * trace_id: string, * parent_span_id?: string, @@ -142,9 +142,9 @@ public static function toEnvelopeItem(Event $event): string * status?: string, * description?: string, * op?: string, + * origin: string, * data?: array, * tags?: array - * _metrics_summary?: array * } */ protected static function serializeSpan(Span $span): array diff --git a/src/Serializer/Traits/BreadcrumbSerializerTrait.php b/src/Serializer/Traits/BreadcrumbSerializerTrait.php index 2299b3bb71..9880bcea7f 100644 --- a/src/Serializer/Traits/BreadcrumbSerializerTrait.php +++ b/src/Serializer/Traits/BreadcrumbSerializerTrait.php @@ -14,7 +14,7 @@ trait BreadcrumbSerializerTrait /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * type: string, * category: string, * level: string, diff --git a/src/Serializer/Traits/StacktraceFrameSerializerTrait.php b/src/Serializer/Traits/StacktraceFrameSerializerTrait.php index bd9f22ccf8..db5533a65f 100644 --- a/src/Serializer/Traits/StacktraceFrameSerializerTrait.php +++ b/src/Serializer/Traits/StacktraceFrameSerializerTrait.php @@ -14,7 +14,7 @@ trait StacktraceFrameSerializerTrait /** * @return array * - * @psalm-return array{ + * @phpstan-return array{ * filename: string, * lineno: int, * in_app: bool, diff --git a/src/StacktraceBuilder.php b/src/StacktraceBuilder.php index 6360c55601..41d97730e9 100644 --- a/src/StacktraceBuilder.php +++ b/src/StacktraceBuilder.php @@ -11,7 +11,7 @@ * This class builds {@see Stacktrace} objects from an instance of an exception * or from a backtrace. * - * @psalm-import-type StacktraceFrame from FrameBuilder + * @phpstan-import-type StacktraceFrame from FrameBuilder */ final class StacktraceBuilder { @@ -52,7 +52,7 @@ public function buildFromException(\Throwable $exception): Stacktrace * @param string $file The file where the backtrace originated from * @param int $line The line from which the backtrace originated from * - * @psalm-param list $backtrace + * @phpstan-param list $backtrace */ public function buildFromBacktrace(array $backtrace, string $file, int $line): Stacktrace { diff --git a/src/State/Hub.php b/src/State/Hub.php index 330e133454..e9132531e0 100644 --- a/src/State/Hub.php +++ b/src/State/Hub.php @@ -311,9 +311,20 @@ public function startTransaction(TransactionContext $context, array $customSampl $transaction->initSpanRecorder(); - $profilesSampleRate = $options->getProfilesSampleRate(); + $profilesSampleSource = 'config:profiles_sample_rate'; + $profilesSampler = $options->getProfilesSampler(); + + if ($profilesSampler !== null) { + $profilesSampleRate = $profilesSampler($samplingContext); + $profilesSampleSource = 'config:profiles_sampler'; + } else { + $profilesSampleRate = $options->getProfilesSampleRate(); + } + if ($profilesSampleRate === null) { - $logger->info(\sprintf('Transaction [%s] is not profiling because `profiles_sample_rate` option is not set.', (string) $transaction->getTraceId())); + $logger->info(\sprintf('Transaction [%s] is not profiling because neither `profiles_sample_rate` nor `profiles_sampler` option is set.', (string) $transaction->getTraceId())); + } elseif (!$this->isValidSampleRate($profilesSampleRate)) { + $logger->warning(\sprintf('Transaction [%s] is not profiling because profile sample rate (decided by %s) is invalid.', (string) $transaction->getTraceId(), $profilesSampleSource)); } elseif ($this->sample($profilesSampleRate)) { $logger->info(\sprintf('Transaction [%s] started profiling because it was sampled.', (string) $transaction->getTraceId())); diff --git a/src/State/HubAdapter.php b/src/State/HubAdapter.php index 9c52108d7b..83272be9b6 100644 --- a/src/State/HubAdapter.php +++ b/src/State/HubAdapter.php @@ -215,7 +215,7 @@ public function __clone() /** * @see https://www.php.net/manual/en/language.oop5.magic.php#object.wakeup */ - public function __wakeup() + public function __wakeup(): void { throw new \BadMethodCallException('Unserializing instances of this class is forbidden.'); } diff --git a/src/State/HubInterface.php b/src/State/HubInterface.php index dcce154ab5..c3c8c67a11 100644 --- a/src/State/HubInterface.php +++ b/src/State/HubInterface.php @@ -52,13 +52,13 @@ public function popScope(): bool; * * @param callable $callback The callback to be executed * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(Scope): T $callback + * @phpstan-param callable(Scope): T $callback * * @return mixed|void The callback's return value, upon successful execution * - * @psalm-return T + * @phpstan-return T */ public function withScope(callable $callback); @@ -112,11 +112,11 @@ public function captureCheckIn(string $slug, CheckInStatus $status, $duration = * * @param string $className The FQCN of the integration * - * @psalm-template T of IntegrationInterface + * @phpstan-template T of IntegrationInterface * - * @psalm-param class-string $className + * @phpstan-param class-string $className * - * @psalm-return T|null + * @phpstan-return T|null */ public function getIntegration(string $className): ?IntegrationInterface; diff --git a/src/State/Scope.php b/src/State/Scope.php index b313eb73ba..58d95f00ff 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -81,7 +81,7 @@ class Scope /** * @var callable[] List of event processors * - * @psalm-var array + * @phpstan-var array */ private $eventProcessors = []; @@ -98,10 +98,15 @@ class Scope /** * @var callable[] List of event processors * - * @psalm-var array + * @phpstan-var array */ private static $globalEventProcessors = []; + /** + * @var callable|null + */ + private static $externalPropagationContextCallback; + public function __construct(?PropagationContext $propagationContext = null) { $this->propagationContext = $propagationContext ?? PropagationContext::fromDefaults(); @@ -367,6 +372,53 @@ public static function addGlobalEventProcessor(callable $eventProcessor): void self::$globalEventProcessors[] = $eventProcessor; } + public static function registerExternalPropagationContext(callable $callback): void + { + self::$externalPropagationContextCallback = $callback; + } + + public static function clearExternalPropagationContext(): void + { + self::$externalPropagationContextCallback = null; + } + + /** + * @return array{trace_id: string, span_id: string}|null + */ + public static function getExternalPropagationContext(): ?array + { + $callback = self::$externalPropagationContextCallback; + if (!\is_callable($callback)) { + return null; + } + + try { + $context = $callback(); + } catch (\Throwable $exception) { + return null; + } + + if (!\is_array($context)) { + return null; + } + + $traceId = $context['trace_id'] ?? null; + $spanId = $context['span_id'] ?? null; + + if (!\is_string($traceId) || preg_match('/^[0-9a-f]{32}$/i', $traceId) !== 1) { + return null; + } + + if (!\is_string($spanId) || preg_match('/^[0-9a-f]{16}$/i', $spanId) !== 1) { + return null; + } + + return [ + 'trace_id' => $traceId, + 'span_id' => $spanId, + ]; + } + /** * Clears the scope and resets any data it contains. * @@ -439,24 +491,30 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op /** * Apply the trace context to errors if there is a Span on the Scope. - * Else fallback to the propagation context. + * Else fallback to the external propagation context or to the + * propagation context. * But do not override a trace context already present. */ - if ($this->span !== null) { - if (!\array_key_exists('trace', $event->getContexts())) { - $event->setContext('trace', $this->span->getTraceContext()); - } + $externalPropagationContext = null; + if ($this->span === null) { + $externalPropagationContext = self::getExternalPropagationContext(); + } + + $traceContext = $this->span !== null + ? $this->span->getTraceContext() + : ($externalPropagationContext ?? $this->propagationContext->getTraceContext()); + if (!\array_key_exists('trace', $event->getContexts())) { + $event->setContext('trace', $traceContext); + } + + if ($this->span !== null) { // Apply the dynamic sampling context to errors if there is a Transaction on the Scope $transaction = $this->span->getTransaction(); if ($transaction !== null) { $event->setSdkMetadata('dynamic_sampling_context', $transaction->getDynamicSamplingContext()); } - } else { - if (!\array_key_exists('trace', $event->getContexts())) { - $event->setContext('trace', $this->propagationContext->getTraceContext()); - } - + } elseif ($externalPropagationContext === null) { $dynamicSamplingContext = $this->propagationContext->getDynamicSamplingContext(); if ($dynamicSamplingContext === null && $options !== null) { $dynamicSamplingContext = DynamicSamplingContext::fromOptions($options, $this); @@ -528,6 +586,33 @@ public function getTransaction(): ?Transaction return null; } + public function hasExternalPropagationContext(): bool + { + return $this->span === null && self::getExternalPropagationContext() !== null; + } + + /** + * @return array{ + * trace_id: string, + * span_id: string, + * parent_span_id?: string, + * data?: array, + * description?: string, + * op?: string, + * status?: string, + * tags?: array, + * origin?: string + * } + */ + public function getTraceContext(): array + { + if ($this->span !== null) { + return $this->span->getTraceContext(); + } + + return self::getExternalPropagationContext() ?? $this->propagationContext->getTraceContext(); + } + public function getPropagationContext(): PropagationContext { return $this->propagationContext; diff --git a/src/Tracing/GuzzleTracingMiddleware.php b/src/Tracing/GuzzleTracingMiddleware.php index c1615dff5a..8c14fe0b07 100644 --- a/src/Tracing/GuzzleTracingMiddleware.php +++ b/src/Tracing/GuzzleTracingMiddleware.php @@ -63,9 +63,15 @@ public static function trace(?HubInterface $hub = null): \Closure } if (self::shouldAttachTracingHeaders($client, $request)) { - $request = $request - ->withHeader('sentry-trace', getTraceparent()) - ->withHeader('baggage', getBaggage()); + $traceParent = getTraceparent(); + if ($traceParent !== '') { + $request = $request->withHeader('sentry-trace', $traceParent); + } + + $baggage = getBaggage(); + if ($baggage !== '') { + $request = $request->withHeader('baggage', $baggage); + } } $handlerPromiseCallback = static function ($responseOrException) use ($hub, $spanAndBreadcrumbData, $childSpan, $parentSpan, $partialUri) { @@ -79,7 +85,6 @@ public static function trace(?HubInterface $hub = null): \Closure $response = null; - /** @psalm-suppress UndefinedClass */ if ($responseOrException instanceof ResponseInterface) { $response = $responseOrException; } elseif ($responseOrException instanceof GuzzleRequestException) { diff --git a/src/Tracing/PropagationContext.php b/src/Tracing/PropagationContext.php index b8c7f6a292..e2d525d5d6 100644 --- a/src/Tracing/PropagationContext.php +++ b/src/Tracing/PropagationContext.php @@ -96,7 +96,7 @@ public function toBaggage(): string } /** - * @return array + * @return array{trace_id: string, span_id: string, parent_span_id?: string} */ public function getTraceContext(): array { diff --git a/src/Tracing/Span.php b/src/Tracing/Span.php index 1d909ad9db..e8df25b88d 100644 --- a/src/Tracing/Span.php +++ b/src/Tracing/Span.php @@ -425,7 +425,7 @@ public function setData(array $data) * * @return array * - * @psalm-return array{ + * @phpstan-return array{ * data?: array, * description?: string, * op?: string, diff --git a/src/Tracing/Traits/TraceHeaderParserTrait.php b/src/Tracing/Traits/TraceHeaderParserTrait.php index 4b1d494dcc..3accc180b1 100644 --- a/src/Tracing/Traits/TraceHeaderParserTrait.php +++ b/src/Tracing/Traits/TraceHeaderParserTrait.php @@ -92,10 +92,11 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin } // Store the propagated trace sample rand or generate a new one - if ($samplingContext->has('sample_rand')) { - $result['sampleRand'] = (float) $samplingContext->get('sample_rand'); - } else { - if ($samplingContext->has('sample_rate') && $result['parentSampled'] !== null) { + if ($hasSentryTrace) { + $incomingSampleRand = self::parseSampleRand($samplingContext); + if ($incomingSampleRand !== null) { + $result['sampleRand'] = $incomingSampleRand; + } elseif ($samplingContext->has('sample_rate') && $result['parentSampled'] !== null) { if ($result['parentSampled'] === true) { // [0, rate) $result['sampleRand'] = round(mt_rand(0, mt_getrandmax() - 1) / mt_getrandmax() * (float) $samplingContext->get('sample_rate'), 6); @@ -112,6 +113,30 @@ protected static function parseTraceAndBaggageHeaders(string $sentryTrace, strin return $result; } + private static function parseSampleRand(DynamicSamplingContext $samplingContext): ?float + { + $sampleRand = $samplingContext->get('sample_rand'); + if ($sampleRand === null) { + return null; + } + + if (is_numeric($sampleRand)) { + $sampleRandAsFloat = (float) $sampleRand; + if ($sampleRandAsFloat >= 0.0 && $sampleRandAsFloat < 1.0) { + return $sampleRandAsFloat; + } + } + + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + $client->getOptions()->getLoggerOrNullLogger()->debug( + 'Ignoring invalid sentry-sample_rand baggage value because it must be a numeric value in the range [0, 1).', + ['sample_rand' => $sampleRand] + ); + + return null; + } + private static function shouldContinueTrace(DynamicSamplingContext $samplingContext): bool { $hub = SentrySdk::getCurrentHub(); diff --git a/src/Unit.php b/src/Unit.php index 5a83ab720b..9e4987ab36 100644 --- a/src/Unit.php +++ b/src/Unit.php @@ -4,7 +4,7 @@ namespace Sentry; -final class Unit implements \Stringable +final class Unit { /** * @var string The value of the enum instance diff --git a/src/Util/CodeLocationResolver.php b/src/Util/CodeLocationResolver.php new file mode 100644 index 0000000000..cc968e70c5 --- /dev/null +++ b/src/Util/CodeLocationResolver.php @@ -0,0 +1,110 @@ +frameBuilder = new FrameBuilder($options, $representationSerializer); + } + + /** + * Resolves the first in-app frame from the current backtrace into code + * location metadata. + * + * @return array{'code.filepath': string, 'code.function': string|null, 'code.lineno': int}|null + */ + public function resolve(int $limit = 20): ?array + { + /** @var list $backtrace */ + $backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, $limit); + + return $this->resolveFromBacktrace($backtrace); + } + + /** + * Resolves the first in-app frame from a backtrace into code location metadata. + * + * @param array> $backtrace The backtrace + * + * @phpstan-param list $backtrace + * + * @return array{'code.filepath': string, 'code.function': string|null, 'code.lineno': int}|null + */ + public function resolveFromBacktrace(array $backtrace): ?array + { + $frame = $this->findFirstInAppFrameForBacktrace($backtrace); + + if ($frame === null) { + return null; + } + + return $this->getCodeLocationForFrame($frame); + } + + /** + * Find the first in-app frame for a given backtrace. + * + * @param array> $backtrace The backtrace + * + * @phpstan-param list $backtrace + */ + public function findFirstInAppFrameForBacktrace(array $backtrace): ?Frame + { + $file = Frame::INTERNAL_FRAME_FILENAME; + $line = 0; + + foreach ($backtrace as $backtraceFrame) { + $frame = $this->frameBuilder->buildFromBacktraceFrame($file, $line, $backtraceFrame); + + if ($frame->isInApp()) { + return $frame; + } + + $file = $backtraceFrame['file'] ?? Frame::INTERNAL_FRAME_FILENAME; + $line = $backtraceFrame['line'] ?? 0; + } + + return null; + } + + /** + * Converts a frame into code location metadata. + * + * @return array{'code.filepath': string, 'code.function': string|null, 'code.lineno': int} + */ + public function getCodeLocationForFrame(Frame $frame): array + { + return [ + 'code.filepath' => $frame->getFile(), + 'code.function' => $frame->getFunctionName(), + 'code.lineno' => $frame->getLine(), + ]; + } +} diff --git a/src/Util/Http.php b/src/Util/Http.php index efe903ad73..ee2490dbb6 100644 --- a/src/Util/Http.php +++ b/src/Util/Http.php @@ -12,10 +12,7 @@ */ final class Http { - /** - * @return string[] - */ - public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): array + public static function getSentryAuthHeader(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): string { $authHeader = [ 'sentry_version=' . Client::PROTOCOL_VERSION, @@ -23,9 +20,17 @@ public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string 'sentry_key=' . $dsn->getPublicKey(), ]; + return 'Sentry ' . implode(', ', $authHeader); + } + + /** + * @return string[] + */ + public static function getRequestHeaders(Dsn $dsn, string $sdkIdentifier, string $sdkVersion): array + { return [ 'Content-Type: application/x-sentry-envelope', - 'X-Sentry-Auth: Sentry ' . implode(', ', $authHeader), + 'X-Sentry-Auth: ' . self::getSentryAuthHeader($dsn, $sdkIdentifier, $sdkVersion), ]; } diff --git a/src/Util/PHPVersion.php b/src/Util/PHPVersion.php index 3f0394b6a4..c388070346 100644 --- a/src/Util/PHPVersion.php +++ b/src/Util/PHPVersion.php @@ -22,6 +22,7 @@ final class PHPVersion */ public static function parseVersion(string $version = \PHP_VERSION): string { + $matches = []; if (!preg_match(self::VERSION_PARSING_REGEX, $version, $matches)) { return $version; } diff --git a/src/Util/TelemetryStorage.php b/src/Util/TelemetryStorage.php new file mode 100644 index 0000000000..2289493305 --- /dev/null +++ b/src/Util/TelemetryStorage.php @@ -0,0 +1,109 @@ + + */ + private $data; + + private function __construct(?int $size = null) + { + if ($size !== null) { + $this->data = new RingBuffer($size); + } else { + $this->data = []; + } + } + + public function count(): int + { + return \count($this->data); + } + + /** + * @param T $value + */ + public function push($value): void + { + if ($this->data instanceof RingBuffer) { + $this->data->push($value); + } else { + $this->data[] = $value; + } + } + + /** + * @return T[] + */ + public function drain(): array + { + if ($this->data instanceof RingBuffer) { + return $this->data->drain(); + } + $data = $this->data; + $this->data = []; + + return $data; + } + + /** + * @return T[] + */ + public function toArray(): array + { + if ($this->data instanceof RingBuffer) { + return $this->data->toArray(); + } + + return $this->data; + } + + public function isEmpty(): bool + { + if ($this->data instanceof RingBuffer) { + return $this->data->isEmpty(); + } + + return empty($this->data); + } + + /** + * Creates a new TelemetryStorage that is not bounded in size. This version should only be used if there + * is another flushing signal available. + * + * @return self + */ + public static function unbounded(): self + { + return new self(); + } + + /** + * Creates a TelemetryStorage that has an upper bound of $size. It will drop the oldest items when new items + * are added while being at capacity. + * + * @return self + */ + public static function bounded(int $size): self + { + return new self($size); + } +} diff --git a/src/functions.php b/src/functions.php index c9471ace0e..0935d739fc 100644 --- a/src/functions.php +++ b/src/functions.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\IntegrationInterface; +use Sentry\Integration\OTLPIntegration; use Sentry\Logs\Logs; use Sentry\Metrics\TraceMetrics; use Sentry\State\Scope; @@ -47,18 +48,20 @@ * in_app_include?: array, * integrations?: IntegrationInterface[]|callable(IntegrationInterface[]): IntegrationInterface[], * logger?: LoggerInterface|null, + * log_flush_threshold?: int|null, + * metric_flush_threshold?: int|null, * max_breadcrumbs?: int, - * max_request_body_size?: "none"|"never"|"small"|"medium"|"always", + * max_request_body_size?: "never"|"small"|"medium"|"always", * org_id?: int|null, * prefixes?: array, * profiles_sample_rate?: int|float|null, + * profiles_sampler?: callable|null, * release?: string|null, * sample_rate?: float|int, * send_attempts?: int, * send_default_pii?: bool, * server_name?: string, * spotlight?: bool, - * spotlight_url?: string, * strict_trace_continuation?: bool, * tags?: array, * trace_propagation_targets?: array|null, @@ -201,13 +204,13 @@ function configureScope(callable $callback): void * * @param callable $callback The callback to be executed * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(Scope): T $callback + * @phpstan-param callable(Scope): T $callback * * @return mixed|void The callback's return value, upon successful execution * - * @psalm-return T + * @phpstan-return T */ function withScope(callable $callback) { @@ -232,13 +235,13 @@ function endContext(?int $timeout = null): void * @param callable $callback The callback to execute * @param int|null $timeout The maximum number of seconds to wait while flushing the client transport * - * @psalm-template T + * @phpstan-template T * - * @psalm-param callable(): T $callback + * @phpstan-param callable(): T $callback * * @return mixed * - * @psalm-return T + * @phpstan-return T */ function withContext(callable $callback, ?int $timeout = null) { @@ -283,6 +286,7 @@ function trace(callable $trace, SpanContext $context) { return SentrySdk::getCurrentHub()->withScope(static function (Scope $scope) use ($context, $trace) { $parentSpan = $scope->getSpan(); + $span = null; // If there is a span set on the scope and it's sampled there is an active transaction. // If that is the case we create the child span and set it on the scope. @@ -296,7 +300,7 @@ function trace(callable $trace, SpanContext $context) try { return $trace($scope); } finally { - if (isset($span)) { + if ($span !== null) { $span->finish(); $scope->setSpan($parentSpan); @@ -305,6 +309,27 @@ function trace(callable $trace, SpanContext $context) }); } +/** + * Returns the OTLP traces endpoint configured for the current client. + */ +function getOtlpTracesEndpointUrl(): ?string +{ + $hub = SentrySdk::getCurrentHub(); + $client = $hub->getClient(); + + $integration = $hub->getIntegration(OTLPIntegration::class); + if ($integration instanceof OTLPIntegration && $integration->getCollectorUrl() !== null) { + return $integration->getCollectorUrl(); + } + + $dsn = $client->getOptions()->getDsn(); + if ($dsn === null) { + return null; + } + + return $dsn->getOtlpTracesEndpointUrl(); +} + /** * Creates the current Sentry traceparent string, to be used as a HTTP header value * or HTML meta tag value. @@ -314,7 +339,9 @@ function trace(callable $trace, SpanContext $context) function getTraceparent(): string { $hub = SentrySdk::getCurrentHub(); - $options = $hub->getClient()->getOptions(); + $client = $hub->getClient(); + $options = $client->getOptions(); + if ($options->isTracingEnabled()) { $span = SentrySdk::getCurrentHub()->getSpan(); if ($span !== null) { @@ -324,6 +351,10 @@ function getTraceparent(): string $traceParent = ''; $hub->configureScope(static function (Scope $scope) use (&$traceParent) { + if ($scope->hasExternalPropagationContext()) { + return; + } + $traceParent = $scope->getPropagationContext()->toTraceparent(); }); @@ -339,7 +370,9 @@ function getTraceparent(): string function getBaggage(): string { $hub = SentrySdk::getCurrentHub(); - $options = $hub->getClient()->getOptions(); + $client = $hub->getClient(); + $options = $client->getOptions(); + if ($options->isTracingEnabled()) { $span = SentrySdk::getCurrentHub()->getSpan(); if ($span !== null) { @@ -349,6 +382,10 @@ function getBaggage(): string $baggage = ''; $hub->configureScope(static function (Scope $scope) use (&$baggage) { + if ($scope->hasExternalPropagationContext()) { + return; + } + $baggage = $scope->getPropagationContext()->toBaggage(); }); @@ -404,6 +441,11 @@ function metrics(): TraceMetrics return TraceMetrics::getInstance(); } +function traceMetrics(): TraceMetrics +{ + return TraceMetrics::getInstance(); +} + /** * Adds a feature flag evaluation to the current scope. * When invoked repeatedly for the same name, the most recent value is used. diff --git a/tests/Benchmark/SpanBench.php b/tests/Benchmark/SpanBench.php deleted file mode 100644 index 0e0878d92c..0000000000 --- a/tests/Benchmark/SpanBench.php +++ /dev/null @@ -1,62 +0,0 @@ -context = continueTrace('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-0', ''); - $this->contextWithTimestamp = continueTrace('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-0', ''); - $this->contextWithTimestamp->setStartTimestamp(microtime(true)); - } - - /** - * @Revs(100000) - * - * @Iterations(10) - */ - public function benchConstructor(): void - { - $span = new Span(); - } - - /** - * @Revs(100000) - * - * @Iterations(10) - */ - public function benchConstructorWithInjectedContext(): void - { - $span = new Span($this->context); - } - - /** - * @Revs(100000) - * - * @Iterations(10) - */ - public function benchConstructorWithInjectedContextAndStartTimestamp(): void - { - $span = new Span($this->contextWithTimestamp); - } -} diff --git a/tests/ClientBuilderTest.php b/tests/ClientBuilderTest.php index 24ee86a0d5..5a3c5316c7 100644 --- a/tests/ClientBuilderTest.php +++ b/tests/ClientBuilderTest.php @@ -23,7 +23,7 @@ final class ClientBuilderTest extends TestCase { - public function testGetOptions() + public function testGetOptions(): void { $options = new Options(); $clientBuilder = new ClientBuilder($options); diff --git a/tests/CodeLocationResolverTest.php b/tests/CodeLocationResolverTest.php new file mode 100644 index 0000000000..4a45f8a239 --- /dev/null +++ b/tests/CodeLocationResolverTest.php @@ -0,0 +1,94 @@ +createResolver([ + 'prefixes' => [], + ]); + + $frame = $resolver->findFirstInAppFrameForBacktrace($this->createQueryBacktrace($expectedLine)); + + $this->assertNotNull($frame); + $this->assertSame(__FILE__, $frame->getFile()); + $this->assertSame($expectedLine, $frame->getLine()); + $this->assertSame('App\\Repository\\UserRepository::findActiveUsers', $frame->getFunctionName()); + } + + public function testResolveFromBacktraceReturnsCodeLocationMetadata(): void + { + $expectedLine = 321; + $resolver = $this->createResolver([ + 'prefixes' => [\dirname(__DIR__)], + ]); + + $location = $resolver->resolveFromBacktrace($this->createQueryBacktrace($expectedLine)); + + $this->assertNotNull($location); + $this->assertSame(\DIRECTORY_SEPARATOR . 'tests' . \DIRECTORY_SEPARATOR . 'CodeLocationResolverTest.php', $location['code.filepath']); + $this->assertSame('App\\Repository\\UserRepository::findActiveUsers', $location['code.function']); + $this->assertSame($expectedLine, $location['code.lineno']); + } + + public function testResolveFromBacktraceReturnsNullWithoutInAppFrame(): void + { + $resolver = $this->createResolver(); + + $location = $resolver->resolveFromBacktrace([ + [ + 'file' => Frame::INTERNAL_FRAME_FILENAME, + 'line' => 0, + 'function' => 'internal', + ], + [ + 'class' => 'Doctrine\\DBAL\\Connection', + 'function' => 'executeQuery', + ], + ]); + + $this->assertNull($location); + } + + private function createResolver(array $options = []): CodeLocationResolver + { + $options = new Options($options); + + return new CodeLocationResolver($options, new RepresentationSerializer($options)); + } + + /** + * @return array> + */ + private function createQueryBacktrace(int $line): array + { + return [ + [ + 'file' => Frame::INTERNAL_FRAME_FILENAME, + 'line' => 0, + 'function' => 'internal', + ], + [ + 'file' => __FILE__, + 'line' => $line, + 'class' => 'Doctrine\\DBAL\\Connection', + 'function' => 'executeQuery', + ], + [ + 'class' => 'App\\Repository\\UserRepository', + 'function' => 'findActiveUsers', + ], + ]; + } +} diff --git a/tests/DsnTest.php b/tests/DsnTest.php index f8bb169d89..129e15f840 100644 --- a/tests/DsnTest.php +++ b/tests/DsnTest.php @@ -245,6 +245,44 @@ public static function getCspReportEndpointUrlDataProvider(): \Generator ]; } + /** + * @dataProvider getOtlpTracesEndpointUrlDataProvider + */ + public function testGetOtlpTracesEndpointUrl(string $value, string $expectedUrl): void + { + $dsn = Dsn::createFromString($value); + + $this->assertSame($expectedUrl, $dsn->getOtlpTracesEndpointUrl()); + } + + public static function getOtlpTracesEndpointUrlDataProvider(): \Generator + { + yield [ + 'http://public@example.com/sentry/1', + 'http://example.com/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'http://public@example.com/1', + 'http://example.com/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'http://public@example.com:8080/sentry/1', + 'http://example.com:8080/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'https://public@example.com/sentry/1', + 'https://example.com/sentry/api/1/integration/otlp/v1/traces/', + ]; + + yield [ + 'https://public@example.com:4343/sentry/1', + 'https://example.com:4343/sentry/api/1/integration/otlp/v1/traces/', + ]; + } + /** * @dataProvider toStringDataProvider */ diff --git a/tests/ExceptionDataBagTest.php b/tests/ExceptionDataBagTest.php index 57d6a8a55e..6508d95ecd 100644 --- a/tests/ExceptionDataBagTest.php +++ b/tests/ExceptionDataBagTest.php @@ -15,7 +15,7 @@ final class ExceptionDataBagTest extends TestCase /** * @dataProvider constructorDataProvider */ - public function testConstructor(array $constructorArgs, string $expectedType, string $expectedValue, ?Stacktrace $expectedStackTrace, ?ExceptionMechanism $expectedExceptionMechansim) + public function testConstructor(array $constructorArgs, string $expectedType, string $expectedValue, ?Stacktrace $expectedStackTrace, ?ExceptionMechanism $expectedExceptionMechansim): void { $exceptionDataBag = new ExceptionDataBag(...$constructorArgs); diff --git a/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php b/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php new file mode 100644 index 0000000000..2118637e80 --- /dev/null +++ b/tests/Fixtures/OpenTelemetry/StubOtelHttpClient.php @@ -0,0 +1,30 @@ + + */ + public static function getCandidates(string $type): array + { + if (is_a(ClientInterface::class, $type, true)) { + return [['class' => StubOtelHttpClient::class, 'condition' => StubOtelHttpClient::class]]; + } + + if (is_a(RequestFactoryInterface::class, $type, true) || is_a(StreamFactoryInterface::class, $type, true)) { + return [['class' => Psr17Factory::class, 'condition' => Psr17Factory::class]]; + } + + return []; + } +} diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index df1260fa21..4611e60547 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -12,6 +12,7 @@ use Sentry\Event; use Sentry\EventHint; use Sentry\EventId; +use Sentry\Integration\OTLPIntegration; use Sentry\MonitorConfig; use Sentry\MonitorSchedule; use Sentry\NoOpClient; @@ -42,6 +43,7 @@ use function Sentry\continueTrace; use function Sentry\endContext; use function Sentry\getBaggage; +use function Sentry\getOtlpTracesEndpointUrl; use function Sentry\getTraceparent; use function Sentry\init; use function Sentry\startContext; @@ -553,6 +555,27 @@ public function testTraceparentWithTracingEnabled(): void $this->assertSame('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', $traceParent); } + public function testTraceHeadersAreEmptyWhenExternalPropagationContextIsActive(): void + { + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $propagationContext->setSpanId(new SpanId('566e3688a61d4bc8')); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + SentrySdk::setCurrentHub(new Hub(new NoOpClient(), new Scope($propagationContext))); + + $this->assertSame('', getTraceparent()); + $this->assertSame('', getBaggage()); + + Scope::clearExternalPropagationContext(); + } + public function testBaggageWithTracingDisabled(): void { $propagationContext = PropagationContext::fromDefaults(); @@ -611,6 +634,43 @@ public function testBaggageWithTracingEnabled(): void $this->assertSame('sentry-trace_id=566e3688a61d4bc888951642d6f14a19,sentry-sample_rate=1,sentry-transaction=Test,sentry-release=1.0.0,sentry-environment=development,sentry-sampled=true,sentry-sample_rand=0.25', $baggage); } + public function testGetOtlpTracesEndpointUrlFallsBackToDsn(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn(null); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame('https://example.com/api/1/integration/otlp/v1/traces/', getOtlpTracesEndpointUrl()); + } + + public function testGetOtlpTracesEndpointUrlPrefersCollectorUrl(): void + { + $integration = new OTLPIntegration(false, 'http://collector:4318/v1/traces'); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn($integration); + $client->method('getOptions') + ->willReturn(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame('http://collector:4318/v1/traces', getOtlpTracesEndpointUrl()); + } + public function testContinueTrace(): void { $hub = new Hub(new NoOpClient()); diff --git a/tests/HttpClient/AgentClientBuilderTest.php b/tests/HttpClient/AgentClientBuilderTest.php new file mode 100644 index 0000000000..ffcd72f0ec --- /dev/null +++ b/tests/HttpClient/AgentClientBuilderTest.php @@ -0,0 +1,171 @@ +serverProcess !== null) { + $this->stopTestServer(); + } + + StubLogger::$logs = []; + } + + public function testBuilderUsesFallbackClientByDefaultWhenLocalAgentIsUnavailable(): void + { + $testServer = $this->startTestServer(); + $dsn = "http://publicKey@{$testServer}/200"; + + $envelope = $this->createEnvelope($dsn, 'Hello from builder default fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => $dsn]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $serverOutput = $this->stopTestServer(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertStringContainsString('Hello from builder default fallback test!', $serverOutput['body']); + } + + public function testBuilderCanDisableFallbackClient(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from disabled fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->disableFallbackClient() + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertFalse($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testBuilderUsesCustomFallbackClientWhenConfigured(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from custom fallback builder test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(201, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setFallbackClient($fallbackClient) + ->getClient(); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + } + + public function testBuilderCreatesDefaultFallbackClientWithConfiguredSdkIdentifierAndVersion(): void + { + $client = AgentClientBuilder::create() + ->setHost(self::UNAVAILABLE_AGENT_HOST) + ->setPort(self::UNAVAILABLE_AGENT_PORT) + ->setSdkIdentifier('sentry.test') + ->setSdkVersion('1.2.3-test') + ->getClient(); + + $fallbackClientFactoryProperty = new \ReflectionProperty($client, 'fallbackClientFactory'); + if (\PHP_VERSION_ID < 80100) { + $fallbackClientFactoryProperty->setAccessible(true); + } + + /** @var mixed $fallbackClientFactory */ + $fallbackClientFactory = $fallbackClientFactoryProperty->getValue($client); + + $this->assertIsCallable($fallbackClientFactory); + $fallbackClient = $fallbackClientFactory(); + $this->assertInstanceOf(HttpClient::class, $fallbackClient); + + $sdkIdentifierProperty = new \ReflectionProperty($fallbackClient, 'sdkIdentifier'); + $sdkVersionProperty = new \ReflectionProperty($fallbackClient, 'sdkVersion'); + if (\PHP_VERSION_ID < 80100) { + $sdkIdentifierProperty->setAccessible(true); + $sdkVersionProperty->setAccessible(true); + } + + $this->assertSame('sentry.test', $sdkIdentifierProperty->getValue($fallbackClient)); + $this->assertSame('1.2.3-test', $sdkVersionProperty->getValue($fallbackClient)); + } + + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } +} diff --git a/tests/HttpClient/AgentClientTest.php b/tests/HttpClient/AgentClientTest.php new file mode 100644 index 0000000000..4d1db39fc8 --- /dev/null +++ b/tests/HttpClient/AgentClientTest.php @@ -0,0 +1,333 @@ +agentProcess !== null) { + $this->stopTestAgent(); + } + + StubLogger::$logs = []; + } + + public function testClientHandsOffEnvelopeToLocalAgent(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from agent client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient('127.0.0.1', $this->agentPort, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, new Options()); + + $this->waitForEnvelopeCount(1); + $agentOutput = $this->stopTestAgent(); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertCount(1, $agentOutput['messages']); + $this->assertStringContainsString('Hello from agent client test!', $agentOutput['messages'][0]); + $this->assertStringContainsString('"type":"event"', $agentOutput['messages'][0]); + } + + public function testClientReturnsErrorAndLogsDebugWhenLocalAgentIsUnavailableWithoutFallback(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from unavailable agent test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, null); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope to the local Sentry agent and no fallback client is available.', $response->getError()); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + } + + public function testClientLazilyInitializesFallbackFactoryOnlyWhenNeeded(): void + { + $this->startTestAgent(); + + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from lazy fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->never()) + ->method('sendRequest'); + + $client = new AgentClient( + '127.0.0.1', + $this->agentPort, + static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + } + ); + $response = $client->sendRequest($request, new Options()); + + $this->waitForEnvelopeCount(1); + $this->stopTestAgent(); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('', $response->getError()); + $this->assertSame(0, $factoryCallCount); + } + + public function testClientUsesFallbackClientWhenLocalAgentIsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options([ + 'dsn' => 'http://public@example.com/1', + 'logger' => $logger, + ]); + + $fallbackResponse = new Response(200, [], ''); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $response); + $this->assertTrue($this->hasLogMessage('Failed to hand off envelope to local Sentry agent.')); + $this->assertTrue($this->hasLogMessage('Using fallback HTTP client because local Sentry agent handoff failed.')); + } + + public function testClientReusesFallbackClientWhenLocalAgentRemainsUnavailable(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from cached fallback test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $options = new Options(['dsn' => 'http://public@example.com/1']); + $fallbackResponse = new Response(200, [], ''); + $factoryCallCount = 0; + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->exactly(2)) + ->method('sendRequest') + ->with($request, $options) + ->willReturn($fallbackResponse); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use (&$factoryCallCount, $fallbackClient): HttpClientInterface { + ++$factoryCallCount; + + return $fallbackClient; + }); + + $firstResponse = $client->sendRequest($request, $options); + $secondResponse = $client->sendRequest($request, $options); + + $this->assertSame($fallbackResponse, $firstResponse); + $this->assertSame($fallbackResponse, $secondResponse); + $this->assertSame(1, $factoryCallCount); + } + + public function testClientDoesNotThrowWhenFallbackClientThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback client test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + /** @var HttpClientInterface&\PHPUnit\Framework\MockObject\MockObject $fallbackClient */ + $fallbackClient = $this->createMock(HttpClientInterface::class); + $fallbackClient->expects($this->once()) + ->method('sendRequest') + ->with($request, $options) + ->willThrowException(new \RuntimeException('fallback boom')); + + $client = new AgentClient(self::UNAVAILABLE_AGENT_HOST, self::UNAVAILABLE_AGENT_PORT, static function () use ($fallbackClient): HttpClientInterface { + return $fallbackClient; + }); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Failed to send envelope using fallback HTTP client. Reason: "fallback boom".', $response->getError()); + $this->assertTrue($this->hasLogMessage('Fallback HTTP client failed while sending envelope.')); + } + + public function testClientReturnsErrorWhenBodyIsEmpty(): void + { + $client = new AgentClient(); + $response = $client->sendRequest(new Request(), new Options()); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertSame('Request body is empty', $response->getError()); + } + + public function testClientDoesNotThrowWhenFallbackFactoryThrows(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessageContaining('Failed to initialize fallback HTTP client.')); + } + + public function testClientLogsFallbackFactoryErrorOnlyOnce(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from repeated throwing fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function (): HttpClientInterface { + throw new \RuntimeException('factory boom'); + } + ); + + $client->sendRequest($request, $options); + $client->sendRequest($request, $options); + + $this->assertSame(1, $this->countLogMessagesContaining('Failed to initialize fallback HTTP client.')); + } + + public function testClientDoesNotThrowWhenFallbackFactoryReturnsUnexpectedValue(): void + { + $envelope = $this->createEnvelope('http://public@example.com/1', 'Hello from invalid fallback factory test!'); + + $request = new Request(); + $request->setStringBody($envelope); + + $logger = StubLogger::getInstance(); + $options = new Options(['logger' => $logger]); + + $client = new AgentClient( + self::UNAVAILABLE_AGENT_HOST, + self::UNAVAILABLE_AGENT_PORT, + static function () { + return new \stdClass(); + } + ); + $response = $client->sendRequest($request, $options); + + $this->assertSame(502, $response->getStatusCode()); + $this->assertTrue($response->hasError()); + $this->assertTrue($this->hasLogMessage('The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.')); + } + + private function createEnvelope(string $dsn, string $message): string + { + $options = new Options(['dsn' => $dsn]); + + $event = Event::createEvent(); + $event->setMessage($message); + + $serializer = new PayloadSerializer($options); + + return $serializer->serialize($event); + } + + private function hasLogMessage(string $message): bool + { + foreach (StubLogger::$logs as $log) { + if ($log['message'] === $message) { + return true; + } + } + + return false; + } + + private function countLogMessagesContaining(string $message): int + { + $result = array_filter(StubLogger::$logs, static function (array $log) use ($message): bool { + return strpos($log['message'], $message) !== false; + }); + + return \count($result); + } + + private function hasLogMessageContaining(string $message): bool + { + return $this->countLogMessagesContaining($message) > 0; + } +} diff --git a/tests/HttpClient/ResponseTest.php b/tests/HttpClient/ResponseTest.php index 44f50d9fb5..6205255cec 100644 --- a/tests/HttpClient/ResponseTest.php +++ b/tests/HttpClient/ResponseTest.php @@ -9,7 +9,7 @@ final class ResponseTest extends TestCase { - public function testResponseSuccess() + public function testResponseSuccess(): void { $response = new Response( 200, @@ -32,7 +32,7 @@ public function testResponseSuccess() $this->assertFalse($response->hasError()); } - public function testResponseFailure() + public function testResponseFailure(): void { $response = new Response( 500, @@ -51,7 +51,7 @@ public function testResponseFailure() $this->assertTrue($response->hasError()); } - public function testResponseMultiValueHeader() + public function testResponseMultiValueHeader(): void { $response = new Response( 200, diff --git a/tests/HttpClient/TestAgent.php b/tests/HttpClient/TestAgent.php new file mode 100644 index 0000000000..9e29063c47 --- /dev/null +++ b/tests/HttpClient/TestAgent.php @@ -0,0 +1,233 @@ +startTestAgent()` to start the agent. + * After you are done, call `$this->stopTestAgent()` to stop the agent and get + * the captured envelopes. + */ +trait TestAgent +{ + /** + * @var resource|null the agent process handle + */ + protected $agentProcess; + + /** + * @var resource|null the agent stderr handle + */ + protected $agentStderr; + + /** + * @var string|null the path to the output file + */ + protected $agentOutputFile; + + /** + * @var int the port on which the agent is listening, this default value was randomly chosen + */ + protected $agentPort = 45848; + + /** + * Start the test agent. + * + * @return string the address the agent is listening on + */ + public function startTestAgent(): string + { + if ($this->agentProcess !== null) { + throw new \RuntimeException('There is already a test agent instance running.'); + } + + $outputFile = tempnam(sys_get_temp_dir(), 'sentry-agent-client-output-'); + + if ($outputFile === false) { + throw new \RuntimeException('Failed to create the output file for the test agent.'); + } + + $this->agentOutputFile = $outputFile; + + $pipes = []; + + $this->agentProcess = proc_open( + $command = \sprintf( + 'php %s %d %s', + escapeshellarg((string) realpath(__DIR__ . '/agent-server.php')), + $this->agentPort, + escapeshellarg($this->agentOutputFile) + ), + [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ], + $pipes + ); + + $this->agentStderr = $pipes[2]; + + $pid = proc_get_status($this->agentProcess)['pid']; + + if (!\is_resource($this->agentProcess)) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + $address = "127.0.0.1:{$this->agentPort}"; + + // Wait for the agent to be ready to accept connections + $startTime = microtime(true); + $timeout = 5; // 5 seconds timeout + + while (true) { + $socket = @stream_socket_client("tcp://{$address}", $errno, $errstr, 1); + + if ($socket !== false) { + fclose($socket); + break; + } + + if (microtime(true) - $startTime > $timeout) { + $this->stopTestAgent(); + throw new \RuntimeException("Timeout waiting for test agent to start on {$address}"); + } + + usleep(10000); + } + + // Ensure the process is still running + if (!proc_get_status($this->agentProcess)['running']) { + throw new \RuntimeException("Error starting test agent on pid {$pid}, command failed: {$command}"); + } + + return $address; + } + + /** + * Wait for the test agent to receive the expected number of envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function waitForEnvelopeCount(int $expectedCount, float $timeout = 5.0): array + { + if ($this->agentProcess === null) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $startTime = microtime(true); + + while (true) { + $output = $this->readAgentOutput(); + + if (\count($output['messages']) >= $expectedCount) { + return $output; + } + + if (microtime(true) - $startTime > $timeout) { + throw new \RuntimeException(\sprintf('Timeout waiting for %d envelope(s), got %d.', $expectedCount, \count($output['messages']))); + } + + usleep(10000); + } + } + + /** + * Stop the test agent and return the captured envelopes. + * + * @return array{ + * messages: string[], + * connections: int, + * } + */ + public function stopTestAgent(): array + { + if (!$this->agentProcess) { + throw new \RuntimeException('There is no test agent instance running.'); + } + + $output = $this->readAgentOutput(); + + for ($i = 0; $i < 20; ++$i) { + $status = proc_get_status($this->agentProcess); + + if (!$status['running']) { + break; + } + + $this->killAgentProcess($status['pid']); + + usleep(10000); + } + + if ($status['running']) { + throw new \RuntimeException('Could not kill test agent'); + } + + proc_close($this->agentProcess); + + if ($this->agentOutputFile !== null && file_exists($this->agentOutputFile)) { + unlink($this->agentOutputFile); + } + + $this->agentProcess = null; + $this->agentStderr = null; + $this->agentOutputFile = null; + + return $output; + } + + /** + * @return array{ + * messages: string[], + * connections: int, + * } + */ + private function readAgentOutput(): array + { + if ($this->agentOutputFile === null || !file_exists($this->agentOutputFile)) { + return ['messages' => [], 'connections' => 0]; + } + + $output = file_get_contents($this->agentOutputFile); + + if ($output === false || $output === '') { + return ['messages' => [], 'connections' => 0]; + } + + $decoded = json_decode($output, true); + + if (!\is_array($decoded)) { + return ['messages' => [], 'connections' => 0]; + } + + return [ + 'messages' => $decoded['messages'] ?? [], + 'connections' => $decoded['connections'] ?? 0, + ]; + } + + private function killAgentProcess(int $pid): void + { + if (\PHP_OS_FAMILY === 'Windows') { + exec("taskkill /pid {$pid} /f /t"); + } else { + // Kills any child processes + exec("pkill -P {$pid}"); + + // Kill the parent process + exec("kill {$pid}"); + } + + proc_terminate($this->agentProcess, 9); + } +} diff --git a/tests/HttpClient/TestServer.php b/tests/HttpClient/TestServer.php index d915187ef2..8b4a1593ac 100644 --- a/tests/HttpClient/TestServer.php +++ b/tests/HttpClient/TestServer.php @@ -34,7 +34,7 @@ trait TestServer /** * @var int the port on which the server is listening, this default value was randomly chosen */ - protected $serverPort = 44884; + protected $serverPort = 45884; public function startTestServer(): string { @@ -50,9 +50,9 @@ public function startTestServer(): string $this->serverProcess = proc_open( $command = \sprintf( - 'php -S localhost:%d -t %s', + 'php -S localhost:%d %s', $this->serverPort, - realpath(__DIR__ . '/../testserver') + realpath(__DIR__ . '/../testserver/index.php') ), [2 => ['pipe', 'w']], $pipes diff --git a/tests/HttpClient/agent-server.php b/tests/HttpClient/agent-server.php new file mode 100644 index 0000000000..d81c981b5c --- /dev/null +++ b/tests/HttpClient/agent-server.php @@ -0,0 +1,82 @@ + \n"); + + exit(1); +} + +$port = (int) $argv[1]; +$outputFile = $argv[2]; + +$server = @stream_socket_server("tcp://127.0.0.1:{$port}", $errorNo, $errorMessage); + +if ($server === false) { + fwrite(\STDERR, sprintf("Failed to start test agent server: [%d] %s\n", $errorNo, $errorMessage)); + + exit(1); +} + +$messages = []; +$connections = 0; + +$writeOutput = static function () use (&$messages, &$connections, $outputFile): void { + file_put_contents($outputFile, json_encode([ + 'messages' => $messages, + 'connections' => $connections, + ])); +}; + +$writeOutput(); + +while ($connection = @stream_socket_accept($server, -1)) { + ++$connections; + $writeOutput(); + + $buffer = ''; + $messageLength = 0; + + while (!feof($connection)) { + $chunk = fread($connection, 8192); + + if ($chunk === false) { + break; + } + + if ($chunk === '') { + continue; + } + + $buffer .= $chunk; + + while (strlen($buffer) >= 4) { + if ($messageLength === 0) { + $unpackedHeader = unpack('N', substr($buffer, 0, 4)); + + if ($unpackedHeader === false) { + break 2; + } + + $messageLength = $unpackedHeader[1]; + } + + if (strlen($buffer) < $messageLength) { + break; + } + + $messages[] = substr($buffer, 4, $messageLength - 4); + $buffer = (string) substr($buffer, $messageLength); + $messageLength = 0; + + $writeOutput(); + } + } + + fclose($connection); +} diff --git a/tests/Integration/OTLPIntegrationTest.php b/tests/Integration/OTLPIntegrationTest.php new file mode 100644 index 0000000000..a0f3e5680f --- /dev/null +++ b/tests/Integration/OTLPIntegrationTest.php @@ -0,0 +1,300 @@ +discoveryStrategies = iterator_to_array($strategies); + } else { + $this->discoveryStrategies = $strategies; + } + } + + if (class_exists(StubOtelHttpClient::class, false)) { + StubOtelHttpClient::reset(); + } + + StubLogger::$logs = []; + } + + protected function tearDown(): void + { + if (class_exists(Context::class) && class_exists(ContextStorage::class)) { + Context::setStorage(new ContextStorage()); + } + + if ($this->discoveryStrategies !== null && class_exists(ClassDiscovery::class)) { + ClassDiscovery::setStrategies($this->discoveryStrategies); + } + + if (class_exists(HttpClientDiscovery::class) && method_exists(HttpClientDiscovery::class, 'reset')) { + HttpClientDiscovery::reset(); + } + + if (class_exists(StubOtelHttpClient::class, false)) { + StubOtelHttpClient::reset(); + } + + parent::tearDown(); + } + + public function testSetupOnceLogsAndSkipsWhenSentryTracingIsEnabled(): void + { + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'logger' => StubLogger::getInstance(), + 'traces_sample_rate' => 1.0, + ])); + + $integration->setupOnce(); + + $this->assertNull(Scope::getExternalPropagationContext()); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame('debug', StubLogger::$logs[0]['level']); + $this->assertStringContainsString('Skipping OTLPIntegration because Sentry tracing is enabled.', StubLogger::$logs[0]['message']); + } + + public function testSetupOnceRegistersExternalPropagationContext(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'dsn' => null, + ])); + $integration->setupOnce(); + + $otelScope = $this->activateOpenTelemetrySpan(); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn($integration); + + try { + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertSame([ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], Scope::getExternalPropagationContext()); + } finally { + $otelScope->detach(); + } + } + + public function testExternalPropagationContextIsIgnoredWhenCurrentClientDoesNotHaveIntegration(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(false); + $integration->setOptions(new Options([ + 'dsn' => null, + ])); + $integration->setupOnce(); + + $otelScope = $this->activateOpenTelemetrySpan(); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getIntegration') + ->with(OTLPIntegration::class) + ->willReturn(null); + + try { + SentrySdk::setCurrentHub(new Hub($client)); + + $this->assertNull(Scope::getExternalPropagationContext()); + } finally { + $otelScope->detach(); + } + } + + public function testSetupOnceCreatesTracerProviderWhenMissing(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertSame($tracerProvider, Globals::tracerProvider()); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(1, StubOtelHttpClient::$requests); + $this->assertSame('https://example.com/api/1/integration/otlp/v1/traces/', (string) StubOtelHttpClient::$requests[0]->getUri()); + $this->assertStringContainsString('sentry_key=public', StubOtelHttpClient::$requests[0]->getHeaderLine('X-Sentry-Auth')); + } + + public function testSetupOnceLogsAndSkipsWhenExistingTracerProviderCannotBeModified(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $existingTracerProvider = new TracerProvider(); + (new SdkBuilder()) + ->setTracerProvider($existingTracerProvider) + ->buildAndRegisterGlobal(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'logger' => StubLogger::getInstance(), + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + $this->assertSame($existingTracerProvider, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(0, StubOtelHttpClient::$requests); + $this->assertCount(1, StubLogger::$logs); + $this->assertStringContainsString('existing OpenTelemetry tracer provider cannot be modified after construction', StubLogger::$logs[0]['message']); + } + + public function testSetupOnceUsesCollectorUrlWithoutSentryAuthHeader(): void + { + $this->requireOpenTelemetry(); + $this->useCapturingHttpClient(); + + $integration = new OTLPIntegration(true, 'http://collector:4318/v1/traces'); + $integration->setOptions(new Options([ + 'dsn' => 'https://public@example.com/1', + ])); + $integration->setupOnce(); + + $tracerProvider = Globals::tracerProvider(); + $this->assertInstanceOf(TracerProvider::class, $tracerProvider); + + $this->exportSpan($tracerProvider); + + $this->assertCount(1, StubOtelHttpClient::$requests); + $this->assertSame('http://collector:4318/v1/traces', (string) StubOtelHttpClient::$requests[0]->getUri()); + $this->assertSame('', StubOtelHttpClient::$requests[0]->getHeaderLine('X-Sentry-Auth')); + } + + public function testSetupOnceLogsAndSkipsExporterSetupWhenEndpointCannotBeResolved(): void + { + $this->requireOpenTelemetry(); + + $integration = new OTLPIntegration(true); + $integration->setOptions(new Options([ + 'dsn' => null, + 'logger' => StubLogger::getInstance(), + ])); + + $integration->setupOnce(); + + $this->assertNotInstanceOf(TracerProvider::class, Globals::tracerProvider()); + $this->assertCount(1, StubLogger::$logs); + $this->assertSame('debug', StubLogger::$logs[0]['level']); + $this->assertStringContainsString('Skipping automatic OTLP exporter setup because neither a DSN nor a collector URL is configured.', StubLogger::$logs[0]['message']); + } + + private function requireOpenTelemetry(): void + { + if (\PHP_VERSION_ID < 80100) { + $this->markTestSkipped('OpenTelemetry integration tests require PHP 8.1 or newer.'); + } + + foreach ([ + Globals::class, + Span::class, + SpanContext::class, + Context::class, + ContextStorage::class, + HttpClientDiscovery::class, + TracerProvider::class, + SdkBuilder::class, + ClassDiscovery::class, + ] as $className) { + if (!class_exists($className) && !interface_exists($className)) { + $this->markTestSkipped(\sprintf('OpenTelemetry integration tests require the optional package that provides "%s".', $className)); + } + } + } + + private function activateOpenTelemetrySpan() + { + return Span::wrap(SpanContext::create( + '771a43a4192642f0b136d5159a501700', + '1234567890abcdef' + ))->activate(); + } + + private function useCapturingHttpClient(): void + { + $this->requireOpenTelemetry(); + + if (method_exists(HttpClientDiscovery::class, 'setDiscoverers')) { + HttpClientDiscovery::setDiscoverers([new TestClientDiscoverer()]); + } else { + ClassDiscovery::prependStrategy(TestDiscoveryStrategy::class); + } + + StubOtelHttpClient::reset(); + } + + private function exportSpan(TracerProvider $tracerProvider): void + { + $span = $tracerProvider + ->getTracer('sentry.tests.otlp') + ->spanBuilder('otlp-test-span') + ->startSpan(); + + $span->end(); + $tracerProvider->shutdown(); + } +} diff --git a/tests/Logs/LogsAggregatorTest.php b/tests/Logs/LogsAggregatorTest.php index 92bcb365d5..5278f5039d 100644 --- a/tests/Logs/LogsAggregatorTest.php +++ b/tests/Logs/LogsAggregatorTest.php @@ -12,6 +12,8 @@ use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\State\Scope; +use Sentry\Tests\StubTransport; +use Sentry\Tracing\PropagationContext; use Sentry\Tracing\Span; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanId; @@ -52,7 +54,7 @@ public function testAttributes(array $attributes, array $expected): void $log->attributes()->toSimpleArray(), static function (string $key) { // We are not testing internal Sentry attributes here, only the ones the user supplied - return strpos($key, 'sentry.') !== 0; + return strpos($key, 'sentry.') !== 0 && $key !== 'server.address'; }, \ARRAY_FILTER_USE_KEY ) @@ -162,6 +164,7 @@ public function testAttributesAreAddedToLogMessage(): void { $client = ClientBuilder::create([ 'enable_logs' => true, + 'send_default_pii' => true, 'release' => '1.0.0', 'environment' => 'production', 'server_name' => 'web-server-01', @@ -198,7 +201,7 @@ public function testAttributesAreAddedToLogMessage(): void $this->assertSame('1.0.0', $attributes->get('sentry.release')->getValue()); $this->assertSame('production', $attributes->get('sentry.environment')->getValue()); - $this->assertSame('web-server-01', $attributes->get('sentry.server.address')->getValue()); + $this->assertSame('web-server-01', $attributes->get('server.address')->getValue()); $this->assertSame('User %s performed action %s', $attributes->get('sentry.message.template')->getValue()); $this->assertSame('566e3688a61d4bc8', $attributes->get('sentry.trace.parent_span_id')->getValue()); $this->assertSame('sentry.php', $attributes->get('sentry.sdk.name')->getValue()); @@ -207,4 +210,139 @@ public function testAttributesAreAddedToLogMessage(): void $this->assertSame('foo@example.com', $attributes->get('user.email')->getValue()); $this->assertSame('my_user', $attributes->get('user.name')->getValue()); } + + public function testUserAttributesCanBeSetManuallyWithDefaultPiiOff(): void + { + $client = ClientBuilder::create([ + 'enable_logs' => true, + 'send_default_pii' => false, + ])->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $hub->configureScope(static function (Scope $scope) { + $userDataBag = new UserDataBag(); + $userDataBag->setId('unique_id'); + $userDataBag->setEmail('foo@example.com'); + $userDataBag->setUsername('my_user'); + $scope->setUser($userDataBag); + }); + + $aggregator = new LogsAggregator(); + $aggregator->add(LogLevel::info(), 'User performed action'); + + $logs = $aggregator->all(); + $this->assertCount(1, $logs); + + $attributes = $logs[0]->attributes(); + + $this->assertSame('unique_id', $attributes->get('user.id')->getValue()); + $this->assertSame('foo@example.com', $attributes->get('user.email')->getValue()); + $this->assertSame('my_user', $attributes->get('user.name')->getValue()); + } + + public function testFlushesImmediatelyWhenThresholdIsReached(): void + { + StubTransport::$events = []; + + $transport = new StubTransport(); + $client = ClientBuilder::create([ + 'enable_logs' => true, + 'log_flush_threshold' => 2, + ])->setTransport($transport)->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $aggregator = new LogsAggregator(); + + $aggregator->add(LogLevel::info(), 'First message'); + + $this->assertCount(1, $aggregator->all()); + $this->assertCount(0, StubTransport::$events); + + $aggregator->add(LogLevel::warn(), 'Second message'); + + $this->assertCount(0, $aggregator->all()); + $this->assertCount(1, StubTransport::$events); + $this->assertCount(2, StubTransport::$events[0]->getLogs()); + $this->assertSame('First message', StubTransport::$events[0]->getLogs()[0]->getBody()); + $this->assertSame('Second message', StubTransport::$events[0]->getLogs()[1]->getBody()); + } + + public function testDoesNotFlushImmediatelyWhenThresholdIsNull(): void + { + StubTransport::$events = []; + + $transport = new StubTransport(); + $client = ClientBuilder::create([ + 'enable_logs' => true, + 'log_flush_threshold' => null, + ])->setTransport($transport)->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + $aggregator = new LogsAggregator(); + + $aggregator->add(LogLevel::info(), 'First message'); + $aggregator->add(LogLevel::warn(), 'Second message'); + + $this->assertCount(2, $aggregator->all()); + $this->assertCount(0, StubTransport::$events); + } + + public function testDoesNotUsePropagationContextSpanIdAsParentSpanIdWhenNoLocalSpanExists(): void + { + $client = ClientBuilder::create([ + 'enable_logs' => true, + ])->getClient(); + + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('771a43a4192642f0b136d5159a501700')); + $propagationContext->setSpanId(new SpanId('1234567890abcdef')); + + $hub = new Hub($client, new Scope($propagationContext)); + SentrySdk::setCurrentHub($hub); + + $aggregator = new LogsAggregator(); + $aggregator->add(LogLevel::info(), 'Test message'); + + $logs = $aggregator->all(); + $this->assertCount(1, $logs); + $this->assertSame('771a43a4192642f0b136d5159a501700', $logs[0]->getTraceId()); + + $parentSpanId = $logs[0]->attributes()->get('sentry.trace.parent_span_id'); + $this->assertNotNull($parentSpanId); + // Log attributes normalize null values to the string "null". + $this->assertSame('null', $parentSpanId->getValue()); + } + + public function testUsesExternalPropagationContextWhenNoLocalSpanExists(): void + { + $client = ClientBuilder::create([ + 'enable_logs' => true, + ])->getClient(); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $aggregator = new LogsAggregator(); + $aggregator->add(LogLevel::info(), 'Test message'); + + $logs = $aggregator->all(); + $this->assertCount(1, $logs); + $this->assertSame('771a43a4192642f0b136d5159a501700', $logs[0]->getTraceId()); + $this->assertSame('1234567890abcdef', $logs[0]->attributes()->get('sentry.trace.parent_span_id')->getValue()); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/Metrics/TraceMetricsTest.php b/tests/Metrics/TraceMetricsTest.php index b7c495b624..1191e076ec 100644 --- a/tests/Metrics/TraceMetricsTest.php +++ b/tests/Metrics/TraceMetricsTest.php @@ -13,8 +13,9 @@ use Sentry\Metrics\Types\Metric; use Sentry\Options; use Sentry\State\HubAdapter; +use Sentry\State\Scope; -use function Sentry\metrics; +use function Sentry\traceMetrics; final class TraceMetricsTest extends TestCase { @@ -26,9 +27,9 @@ protected function setUp(): void public function testCounterMetrics(): void { - metrics()->count('test-count', 2, ['foo' => 'bar']); - metrics()->count('test-count', 2, ['foo' => 'bar']); - 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 +44,8 @@ public function testCounterMetrics(): void public function testGaugeMetrics(): void { - metrics()->gauge('test-gauge', 10, ['foo' => 'bar']); - metrics()->flush(); + traceMetrics()->gauge('test-gauge', 10, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -59,8 +60,8 @@ public function testGaugeMetrics(): void public function testDistributionMetrics(): void { - metrics()->distribution('test-distribution', 10, ['foo' => 'bar']); - 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()); @@ -72,15 +73,59 @@ public function testDistributionMetrics(): void $this->assertArrayHasKey('foo', $metric->getAttributes()->toSimpleArray()); } - public function testMetricsBufferFull(): void + public function testFlushesImmediatelyWhenMetricFlushThresholdIsReached(): void { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => 2, + ]), StubTransport::getInstance())); + + traceMetrics()->count('first-metric', 1, ['foo' => 'bar']); + + $this->assertCount(0, StubTransport::$events); + + traceMetrics()->count('second-metric', 2, ['foo' => 'bar']); + + $this->assertCount(1, StubTransport::$events); + $event = StubTransport::$events[0]; + + $this->assertCount(2, $event->getMetrics()); + $this->assertSame('first-metric', $event->getMetrics()[0]->getName()); + $this->assertSame('second-metric', $event->getMetrics()[1]->getName()); + } + + public function testDoesNotFlushImmediatelyWhenMetricFlushThresholdIsNull(): void + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => null, + ]), StubTransport::getInstance())); + + traceMetrics()->count('first-metric', 1, ['foo' => 'bar']); + traceMetrics()->count('second-metric', 2, ['foo' => 'bar']); + + $this->assertCount(0, StubTransport::$events); + + traceMetrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $this->assertCount(2, StubTransport::$events[0]->getMetrics()); + } + + public function testMetricsBufferFullWhenMetricFlushThresholdIsNull(): void + { + HubAdapter::getInstance()->bindClient(new Client(new Options([ + 'metric_flush_threshold' => null, + ]), StubTransport::getInstance())); + for ($i = 0; $i < MetricsAggregator::METRICS_BUFFER_SIZE + 100; ++$i) { - metrics()->count('test', 1, ['foo' => 'bar']); + traceMetrics()->count('test', 1, ['foo' => 'bar']); } - metrics()->flush(); + + traceMetrics()->flush(); + $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; $metrics = $event->getMetrics(); + $this->assertCount(MetricsAggregator::METRICS_BUFFER_SIZE, $metrics); } @@ -90,13 +135,13 @@ public function testEnableMetrics(): void 'enable_metrics' => false, ]), StubTransport::getInstance())); - metrics()->count('test-count', 2, ['foo' => 'bar']); - metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertEmpty(StubTransport::$events); } - public function testBeforeSendMetricAltersContent() + public function testBeforeSendMetricAltersContent(): void { HubAdapter::getInstance()->bindClient(new Client(new Options([ 'before_send_metric' => static function (Metric $metric) { @@ -106,8 +151,8 @@ public function testBeforeSendMetricAltersContent() }, ]), StubTransport::getInstance())); - metrics()->count('test-count', 2, ['foo' => 'bar']); - metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -117,10 +162,10 @@ public function testBeforeSendMetricAltersContent() $this->assertEquals(99999, $metric->getValue()); } - public function testIntType() + public function testIntType(): void { - metrics()->count('test-count', 2, ['foo' => 'bar']); - metrics()->flush(); + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -134,8 +179,8 @@ public function testIntType() public function testFloatType(): void { - metrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); - metrics()->flush(); + traceMetrics()->gauge('test-gauge', 10.50, ['foo' => 'bar']); + traceMetrics()->flush(); $this->assertCount(1, StubTransport::$events); $event = StubTransport::$events[0]; @@ -150,9 +195,29 @@ public function testFloatType(): void public function testInvalidTypeIsDiscarded(): void { // @phpstan-ignore-next-line - metrics()->count('test-count', 'test-value'); - metrics()->flush(); + traceMetrics()->count('test-count', 'test-value'); + traceMetrics()->flush(); $this->assertEmpty(StubTransport::$events); } + + public function testMetricsUseExternalPropagationContextWhenNoLocalSpanExists(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + traceMetrics()->count('test-count', 2, ['foo' => 'bar']); + traceMetrics()->flush(); + + $this->assertCount(1, StubTransport::$events); + $metric = StubTransport::$events[0]->getMetrics()[0]; + $this->assertSame('771a43a4192642f0b136d5159a501700', (string) $metric->getTraceId()); + $this->assertSame('1234567890abcdef', (string) $metric->getSpanId()); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/Monolog/ExceptionToSentryIssueHandlerTest.php b/tests/Monolog/ExceptionToSentryIssueHandlerTest.php new file mode 100644 index 0000000000..20dd681397 --- /dev/null +++ b/tests/Monolog/ExceptionToSentryIssueHandlerTest.php @@ -0,0 +1,230 @@ + $record + * @param array $expectedExtra + */ + public function testHandleCapturesExceptionAndAddsMetadata($record, \Throwable $exception, array $expectedExtra): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with( + $this->identicalTo($exception), + $this->callback(function (Scope $scopeArg) use ($expectedExtra): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedExtra, $event->getExtra()); + + return true; + }), + null + ); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope())); + + $this->assertTrue($handler->isHandling($record)); + $handler->handle($record); + } + + public function testHandleReturnsFalseWhenBubblingEnabled(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with($this->identicalTo($exception), $this->isInstanceOf(Scope::class), null); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleReturnsTrueWhenBubblingDisabled(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with($this->identicalTo($exception), $this->isInstanceOf(Scope::class), null); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + /** + * @dataProvider ignoredRecordsDataProvider + * + * @param LogRecord|array $record + */ + public function testHandleIgnoresRecordsWithoutThrowable($record): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureException'); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleIgnoresRecordsBelowThreshold(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureException'); + + $handler = new ExceptionToSentryIssueHandler(new Hub($client, new Scope()), Logger::ERROR, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertFalse($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testLegacyIsHandlingUsesMinimalLevelRecord(): void + { + if (Logger::API >= 3) { + $this->markTestSkipped('Test only works for Monolog < 3'); + } + + $handler = new ExceptionToSentryIssueHandler(new Hub($this->createMock(ClientInterface::class), new Scope()), Logger::WARNING); + + $this->assertTrue($handler->isHandling(['level' => Logger::WARNING])); + $this->assertFalse($handler->isHandling(['level' => Logger::INFO])); + } + + /** + * @return iterable}> + */ + public static function ignoredRecordsDataProvider(): iterable + { + yield [ + RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []), + ]; + + yield [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => 'not an exception', + ], + [] + ), + ]; + } + + /** + * @return iterable, \Throwable, array}> + */ + public static function capturedRecordsDataProvider(): iterable + { + $exception = new \RuntimeException('exception message'); + + yield 'with exception only' => [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ), + $exception, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.message' => 'foo bar', + ], + ]; + + $exception = new \RuntimeException('exception message'); + + yield 'with context and extra' => [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + $exception, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.message' => 'foo bar', + 'monolog.context' => [ + 'foo' => 'bar', + ], + 'monolog.extra' => [ + 'bar' => 'baz', + ], + ], + ]; + } +} diff --git a/tests/Monolog/LogToSentryIssueHandlerTest.php b/tests/Monolog/LogToSentryIssueHandlerTest.php new file mode 100644 index 0000000000..7bbff023b4 --- /dev/null +++ b/tests/Monolog/LogToSentryIssueHandlerTest.php @@ -0,0 +1,295 @@ + $record + * @param array $expectedExtra + */ + public function testHandleCapturesLogMessageAsIssue(bool $fillExtraContext, $record, Severity $expectedSeverity, array $expectedExtra): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with( + $this->callback(function (Event $event) use ($expectedSeverity): bool { + $this->assertEquals($expectedSeverity, $event->getLevel()); + $this->assertSame('foo bar', $event->getMessage()); + $this->assertSame('monolog.channel.foo', $event->getLogger()); + + return true; + }), + $this->callback(function (EventHint $hint): bool { + $this->assertNull($hint->exception); + $this->assertNull($hint->mechanism); + $this->assertNull($hint->stacktrace); + $this->assertSame([], $hint->extra); + + return true; + }), + $this->callback(function (Scope $scopeArg) use ($expectedExtra): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedExtra, $event->getExtra()); + + return true; + }) + ); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, true, $fillExtraContext); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleReturnsTrueWhenBubblingDisabled(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->isInstanceOf(Event::class), $this->isInstanceOf(EventHint::class), $this->isInstanceOf(Scope::class)); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING, false); + $record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + public function testHandleIgnoresRecordsWithThrowableExceptionContext(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureEvent'); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => new \RuntimeException('boom'), + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleCapturesRecordsWithNonThrowableExceptionContext(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with( + $this->isInstanceOf(Event::class), + $this->isInstanceOf(EventHint::class), + $this->callback(function (Scope $scopeArg): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'exception' => 'not an exception', + ], + ], $event->getExtra()); + + return true; + }) + ); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false, true); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => 'not an exception', + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + public function testHandleIgnoresRecordsBelowThreshold(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureEvent'); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::ERROR, false); + $record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []); + + $this->assertFalse($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testLegacyIsHandlingUsesMinimalLevelRecord(): void + { + if (Logger::API >= 3) { + $this->markTestSkipped('Test only works for Monolog < 3'); + } + + $handler = new LogToSentryIssueHandler(new Hub($this->createMock(ClientInterface::class), new Scope()), Logger::WARNING); + + $this->assertTrue($handler->isHandling(['level' => Logger::WARNING])); + $this->assertFalse($handler->isHandling(['level' => Logger::INFO])); + } + + public function testLogAndExceptionIssueHandlersReplaceLegacyHandlerUseCases(): void + { + $client = ClientBuilder::create() + ->setTransport(StubTransport::getInstance()) + ->getClient(); + $hub = new Hub($client, new Scope()); + + $logger = new Logger('channel.foo', [ + new LogToSentryIssueHandler($hub, Logger::WARNING, true, true), + new ExceptionToSentryIssueHandler($hub, Logger::WARNING), + ]); + + $logger->warning('plain warning', [ + 'foo' => 'bar', + ]); + + $exception = new \RuntimeException('boom'); + $logger->error('exception error', [ + 'exception' => $exception, + 'foo' => 'bar', + ]); + + $this->assertCount(2, StubTransport::$events); + + $logEvent = StubTransport::$events[0]; + $this->assertSame('plain warning', $logEvent->getMessage()); + $this->assertEquals(Severity::warning(), $logEvent->getLevel()); + $this->assertSame('monolog.channel.foo', $logEvent->getLogger()); + $this->assertSame([], $logEvent->getExceptions()); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'foo' => 'bar', + ], + ], $logEvent->getExtra()); + + $exceptionEvent = StubTransport::$events[1]; + $this->assertNull($exceptionEvent->getMessage()); + $this->assertCount(1, $exceptionEvent->getExceptions()); + $this->assertSame(\RuntimeException::class, $exceptionEvent->getExceptions()[0]->getType()); + $this->assertSame('boom', $exceptionEvent->getExceptions()[0]->getValue()); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::ERROR), + 'monolog.message' => 'exception error', + 'monolog.context' => [ + 'foo' => 'bar', + ], + ], $exceptionEvent->getExtra()); + } + + /** + * @return iterable, Severity, array}> + */ + public static function capturedRecordsDataProvider(): iterable + { + foreach ([ + Logger::DEBUG => Severity::debug(), + Logger::INFO => Severity::info(), + Logger::NOTICE => Severity::info(), + Logger::WARNING => Severity::warning(), + Logger::ERROR => Severity::error(), + Logger::CRITICAL => Severity::fatal(), + Logger::ALERT => Severity::fatal(), + Logger::EMERGENCY => Severity::fatal(), + ] as $level => $severity) { + yield Logger::getLevelName($level) => [ + false, + RecordFactory::create('foo bar', $level, 'channel.foo', [], []), + $severity, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName($level), + ], + ]; + } + + yield 'with context and extra' => [ + true, + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + Severity::warning(), + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'foo' => 'bar', + ], + 'monolog.extra' => [ + 'bar' => 'baz', + ], + ], + ]; + + yield 'without context and extra by default' => [ + false, + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + Severity::warning(), + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + ], + ]; + } +} diff --git a/tests/Monolog/LogsHandlerTest.php b/tests/Monolog/LogsHandlerTest.php index df27ae4c8f..f3f2965cc3 100644 --- a/tests/Monolog/LogsHandlerTest.php +++ b/tests/Monolog/LogsHandlerTest.php @@ -54,7 +54,7 @@ public function testHandle($record, Log $expectedLog): void $log->attributes()->toSimpleArray(), static function (string $key) { // We are not testing Sentry's own attributes here, only the ones the user supplied so filter them out of the expected attributes - return strpos($key, 'sentry.') !== 0; + return strpos($key, 'sentry.') !== 0 && $key !== 'server.address'; }, \ARRAY_FILTER_USE_KEY ) @@ -100,7 +100,7 @@ public function testFiltersAndMapsUsingMonologEnumThreshold($threshold, $recordL } } - public function testLogsHandlerDestructor() + public function testLogsHandlerDestructor(): void { $transport = new StubTransport(); $client = ClientBuilder::create([ @@ -135,7 +135,7 @@ public function testOriginTagAppliedWithHandler(): void $this->assertSame('auto.log.monolog', $log->attributes()->toSimpleArray()['sentry.origin']); } - public function testOriginTagNotAppliedWhenUsingDirectly() + public function testOriginTagNotAppliedWhenUsingDirectly(): void { \Sentry\logger()->info('No origin attribute'); diff --git a/tests/OptionResolverTest.php b/tests/OptionResolverTest.php index 176b2dd17d..5c6ec630c2 100644 --- a/tests/OptionResolverTest.php +++ b/tests/OptionResolverTest.php @@ -109,7 +109,7 @@ public function testResolveOnly(array $defaults, array $options, array $expected $this->assertEquals($expectedResult, $result); } - public function testNormalizerReturnsInvalidType() + public function testNormalizerReturnsInvalidType(): void { $resolver = new OptionsResolver(); $resolver->setDefaults(['foo' => 'bar']); @@ -121,7 +121,7 @@ public function testNormalizerReturnsInvalidType() $this->assertEquals(['foo' => 'bar'], $result); } - public function testNormalizerReturnsInvalidValue() + public function testNormalizerReturnsInvalidValue(): void { $resolver = new OptionsResolver(); $resolver->setDefaults(['foo' => 'b']); @@ -133,7 +133,7 @@ public function testNormalizerReturnsInvalidValue() $this->assertEquals(['foo' => 'b'], $result); } - public function testNormalizerResultFailsValidation() + public function testNormalizerResultFailsValidation(): void { $resolver = new OptionsResolver(); $resolver->setDefaults(['foo' => 'b']); diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index da08699001..546eae9da6 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -100,6 +100,34 @@ public static function optionsDataProvider(): \Generator 'setEnableLogs', ]; + yield [ + 'log_flush_threshold', + 10, + 'getLogFlushThreshold', + 'setLogFlushThreshold', + ]; + + yield [ + 'log_flush_threshold', + null, + 'getLogFlushThreshold', + 'setLogFlushThreshold', + ]; + + yield [ + 'metric_flush_threshold', + 10, + 'getMetricFlushThreshold', + 'setMetricFlushThreshold', + ]; + + yield [ + 'metric_flush_threshold', + null, + 'getMetricFlushThreshold', + 'setMetricFlushThreshold', + ]; + yield [ 'traces_sample_rate', 0.5, @@ -128,6 +156,13 @@ static function (): void {}, 'setProfilesSampleRate', ]; + yield [ + 'profiles_sampler', + static function (): void {}, + 'getProfilesSampler', + 'setProfilesSampler', + ]; + yield [ 'attach_stacktrace', false, @@ -512,7 +547,7 @@ public function excludedPathProviders(): array /** * @dataProvider includedPathProviders */ - public function testIncludedAppPathsOverrideExcludedAppPaths(string $value, string $expected) + public function testIncludedAppPathsOverrideExcludedAppPaths(string $value, string $expected): void { $configuration = new Options(['in_app_include' => [$value]]); @@ -585,6 +620,52 @@ public static function contextLinesOptionValidatesInputValueDataProvider(): \Gen ]; } + /** + * @dataProvider logFlushThresholdOptionIsValidatedCorrectlyDataProvider + */ + public function testLogFlushThresholdOptionIsValidatedCorrectly($value, ?int $expectedValue): void + { + $options = new Options(['log_flush_threshold' => $value]); + + $this->assertSame($expectedValue, $options->getLogFlushThreshold()); + } + + public static function logFlushThresholdOptionIsValidatedCorrectlyDataProvider(): array + { + return [ + [-1, null], + [0, null], + [1, 1], + [10, 10], + [null, null], + ['string', null], + ['1', null], + ]; + } + + /** + * @dataProvider metricFlushThresholdOptionIsValidatedCorrectlyDataProvider + */ + public function testMetricFlushThresholdOptionIsValidatedCorrectly($value, ?int $expectedValue): void + { + $options = new Options(['metric_flush_threshold' => $value]); + + $this->assertSame($expectedValue, $options->getMetricFlushThreshold()); + } + + public static function metricFlushThresholdOptionIsValidatedCorrectlyDataProvider(): array + { + return [ + [-1, null], + [0, null], + [1, 1], + [10, 10], + [null, null], + ['string', null], + ['1', null], + ]; + } + /** * @backupGlobals enabled */ diff --git a/tests/SentrySdkExtension.php b/tests/SentrySdkExtension.php index 82ce507612..b50bc2ed85 100644 --- a/tests/SentrySdkExtension.php +++ b/tests/SentrySdkExtension.php @@ -31,6 +31,8 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + StubTransport::$events = []; + $reflectionProperty = new \ReflectionProperty(Scope::class, 'globalEventProcessors'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); @@ -40,6 +42,15 @@ public function executeBeforeTest(string $test): void $reflectionProperty->setAccessible(false); } + $reflectionProperty = new \ReflectionProperty(Scope::class, 'externalPropagationContextCallback'); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(true); + } + $reflectionProperty->setValue(null, null); + if (\PHP_VERSION_ID < 80100) { + $reflectionProperty->setAccessible(false); + } + $reflectionProperty = new \ReflectionProperty(IntegrationRegistry::class, 'integrations'); if (\PHP_VERSION_ID < 80100) { $reflectionProperty->setAccessible(true); diff --git a/tests/Serializer/AbstractSerializerTest.php b/tests/Serializer/AbstractSerializerTest.php index a40479ad7e..71cebdd364 100644 --- a/tests/Serializer/AbstractSerializerTest.php +++ b/tests/Serializer/AbstractSerializerTest.php @@ -66,6 +66,56 @@ public function testEnumsAreNames(): void $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestEnum::CASE_NAME', $result); } + /** + * @requires PHP >= 8.1 + */ + public function testBackedEnumsIncludeValue(): void + { + $serializer = $this->createSerializer(); + $input = SerializerTestBackedEnum::CASE_NAME; + $result = $this->invokeSerialization($serializer, $input); + + $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestBackedEnum::CASE_NAME(case_value)', $result); + } + + /** + * @requires PHP >= 8.1 + * + * @dataProvider serializeAllObjectsDataProvider + */ + public function testEnumsAreNotSerializedAsObjects(bool $serializeAllObjects): void + { + $serializer = $this->createSerializer(); + + if ($serializeAllObjects) { + $serializer->setSerializeAllObjects(true); + } + + $input = SerializerTestEnum::CASE_NAME; + $result = $this->invokeSerialization($serializer, $input); + + $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestEnum::CASE_NAME', $result); + } + + /** + * @requires PHP >= 8.1 + * + * @dataProvider serializeAllObjectsDataProvider + */ + public function testBackedEnumsAreNotSerializedAsObjects(bool $serializeAllObjects): void + { + $serializer = $this->createSerializer(); + + if ($serializeAllObjects) { + $serializer->setSerializeAllObjects(true); + } + + $input = SerializerTestBackedEnum::CASE_NAME; + $result = $this->invokeSerialization($serializer, $input); + + $this->assertSame('Enum Sentry\Tests\Serializer\SerializerTestBackedEnum::CASE_NAME(case_value)', $result); + } + public static function objectsWithIdPropertyDataProvider(): array { return [ diff --git a/tests/Serializer/SerializerTestBackedEnum.php b/tests/Serializer/SerializerTestBackedEnum.php new file mode 100644 index 0000000000..7a4ee78ac6 --- /dev/null +++ b/tests/Serializer/SerializerTestBackedEnum.php @@ -0,0 +1,10 @@ + [ + new Options([ + 'traces_sample_rate' => 1.0, + ]), + TransactionContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', + 'sentry-sample_rand=2.0' + ), + true, + ]; + yield 'Out of range sample rate returned from traces_sampler (lower than minimum)' => [ new Options([ 'traces_sampler' => static function (): float { @@ -824,6 +835,127 @@ public function testStartTransactionWithCustomSamplingContext(): void $hub->startTransaction(new TransactionContext(), $customSamplingContext); } + public function testStartTransactionStartsProfilerWithProfilesSampler(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->exactly(2)) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 1.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNotNull($transaction->getProfiler()); + } + + public function testStartTransactionDoesNotStartProfilerWhenProfilesSamplerReturnsZero(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 0.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testStartTransactionPrefersProfilesSamplerOverProfilesSampleRate(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sample_rate' => 1.0, + 'profiles_sampler' => static function (): float { + return 0.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testStartTransactionWithProfilesSamplerReceivesCustomSamplingContext(): void + { + $customSamplingContext = ['a' => 'b']; + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => function (SamplingContext $samplingContext) use ($customSamplingContext): float { + $this->assertSame($samplingContext->getAdditionalContext(), $customSamplingContext); + + return 0.0; + }, + ])); + + $hub = new Hub($client); + $hub->startTransaction(new TransactionContext(), $customSamplingContext); + } + + public function testStartTransactionDoesNotStartProfilerWhenProfilesSamplerReturnsInvalidValue(): void + { + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 1.0, + 'profiles_sampler' => static function (): string { + return 'foo'; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertTrue($transaction->getSampled()); + $this->assertNull($transaction->getProfiler()); + } + + public function testStartTransactionDoesNotCallProfilesSamplerWhenTransactionIsNotSampled(): void + { + $profilesSamplerInvoked = false; + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('getOptions') + ->willReturn(new Options([ + 'traces_sample_rate' => 0.0, + 'profiles_sampler' => static function () use (&$profilesSamplerInvoked): float { + $profilesSamplerInvoked = true; + + return 1.0; + }, + ])); + + $hub = new Hub($client); + $transaction = $hub->startTransaction(new TransactionContext()); + + $this->assertFalse($transaction->getSampled()); + $this->assertFalse($profilesSamplerInvoked); + $this->assertNull($transaction->getProfiler()); + } + public function testStartTransactionUpdatesTheDscSampleRate(): void { $client = $this->createMock(ClientInterface::class); diff --git a/tests/State/ScopeTest.php b/tests/State/ScopeTest.php index b5f96f7dc2..a9a9d3e8aa 100644 --- a/tests/State/ScopeTest.php +++ b/tests/State/ScopeTest.php @@ -9,6 +9,7 @@ use Sentry\Breadcrumb; use Sentry\Event; use Sentry\EventHint; +use Sentry\Options; use Sentry\Severity; use Sentry\State\Scope; use Sentry\Tracing\DynamicSamplingContext; @@ -548,4 +549,84 @@ public function eventWithLogCountProvider(): \Generator yield 'check-in' => [Event::createCheckIn(), 0]; yield 'logs' => [Event::createLogs(), 0]; } + + public function testGetTraceContextPrefersExternalPropagationContextOverPropagationContext(): void + { + $propagationContext = PropagationContext::fromDefaults(); + $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $propagationContext->setSpanId(new SpanId('566e3688a61d4bc8')); + + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $scope = new Scope($propagationContext); + + $this->assertSame([ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], $scope->getTraceContext()); + + Scope::clearExternalPropagationContext(); + } + + public function testGetTraceContextPrefersLocalSpanOverExternalPropagationContext(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $transaction = new Transaction(new TransactionContext('foo')); + $transaction->setSpanId(new SpanId('8c2df92a922b4efe')); + $transaction->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); + $span = $transaction->startChild(new SpanContext()); + $span->setSpanId(new SpanId('566e3688a61d4bc8')); + + $scope = new Scope(); + $scope->setSpan($span); + + $this->assertSame([ + 'span_id' => '566e3688a61d4bc8', + 'trace_id' => '566e3688a61d4bc888951642d6f14a19', + 'origin' => 'manual', + 'parent_span_id' => '8c2df92a922b4efe', + ], $scope->getTraceContext()); + + Scope::clearExternalPropagationContext(); + } + + public function testApplyToEventSkipsDynamicSamplingContextWhenUsingExternalPropagationContext(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $scope = new Scope(); + $event = $scope->applyToEvent(Event::createEvent(), null, new Options([ + 'dsn' => 'http://public@example.com/1', + 'release' => '1.0.0', + 'environment' => 'test', + 'traces_sample_rate' => 1.0, + ])); + + $this->assertNotNull($event); + $this->assertSame([ + 'trace' => [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ], + ], $event->getContexts()); + $this->assertNull($event->getSdkMetadata('dynamic_sampling_context')); + + Scope::clearExternalPropagationContext(); + } } diff --git a/tests/Tracing/GuzzleTracingMiddlewareTest.php b/tests/Tracing/GuzzleTracingMiddlewareTest.php index f43b7fcf27..becfa84a8e 100644 --- a/tests/Tracing/GuzzleTracingMiddlewareTest.php +++ b/tests/Tracing/GuzzleTracingMiddlewareTest.php @@ -15,6 +15,7 @@ use Sentry\Event; use Sentry\EventType; use Sentry\Options; +use Sentry\SentrySdk; use Sentry\State\Hub; use Sentry\State\Scope; use Sentry\Tracing\GuzzleTracingMiddleware; @@ -33,6 +34,7 @@ public function testTraceCreatesBreadcrumbIfSpanIsNotSet(): void ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(TransactionContext::make()); @@ -77,6 +79,7 @@ public function testTraceCreatesBreadcrumbIfSpanIsRecorded(): void ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(TransactionContext::make()); @@ -118,11 +121,12 @@ public function testTraceCreatesBreadcrumbIfSpanIsRecorded(): void public function testTraceHeaders(Request $request, Options $options, bool $headersShouldBePresent): void { $client = $this->createMock(ClientInterface::class); - $client->expects($this->once()) + $client->expects($this->atLeastOnce()) ->method('getOptions') ->willReturn($options); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $expectedPromiseResult = new Response(); @@ -154,6 +158,7 @@ public function testTraceHeadersWithTransaction(Request $request, Options $optio ->willReturn($options); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $transaction = $hub->startTransaction(new TransactionContext()); @@ -180,6 +185,39 @@ public function testTraceHeadersWithTransaction(Request $request, Options $optio $transaction->finish(); } + public function testTraceHeadersAreNotAddedWhenExternalPropagationContextIsActive(): void + { + Scope::registerExternalPropagationContext(static function (): array { + return [ + 'trace_id' => '771a43a4192642f0b136d5159a501700', + 'span_id' => '1234567890abcdef', + ]; + }); + + $client = $this->createMock(ClientInterface::class); + $client->expects($this->atLeastOnce()) + ->method('getOptions') + ->willReturn(new Options([ + 'trace_propagation_targets' => null, + ])); + + $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); + $expectedPromiseResult = new Response(); + + $middleware = GuzzleTracingMiddleware::trace($hub); + $function = $middleware(function (Request $request) use ($expectedPromiseResult): PromiseInterface { + $this->assertEmpty($request->getHeader('sentry-trace')); + $this->assertEmpty($request->getHeader('baggage')); + + return new FulfilledPromise($expectedPromiseResult); + }); + + $function(new Request('GET', 'https://www.example.com'), []); + + Scope::clearExternalPropagationContext(); + } + public static function traceHeadersDataProvider(): iterable { // Test cases here are duplicated with sampling enabled and disabled because trace headers hould be added regardless of the sample decision @@ -282,7 +320,7 @@ public static function traceHeadersDataProvider(): iterable public function testTrace(Request $request, $expectedPromiseResult, array $expectedBreadcrumbData, array $expectedSpanData): void { $client = $this->createMock(ClientInterface::class); - $client->expects($this->exactly(4)) + $client->expects($this->atLeast(4)) ->method('getOptions') ->willReturn(new Options([ 'traces_sample_rate' => 1, @@ -292,6 +330,7 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec ])); $hub = new Hub($client); + SentrySdk::setCurrentHub($hub); $client->expects($this->once()) ->method('captureEvent') @@ -341,6 +380,7 @@ public function testTrace(Request $request, $expectedPromiseResult, array $expec $function = $middleware(function (Request $request) use ($expectedPromiseResult): PromiseInterface { $this->assertNotEmpty($request->getHeader('sentry-trace')); $this->assertNotEmpty($request->getHeader('baggage')); + if ($expectedPromiseResult instanceof \Throwable) { return new RejectedPromise($expectedPromiseResult); } diff --git a/tests/Tracing/PropagationContextTest.php b/tests/Tracing/PropagationContextTest.php index 2b6c4600b5..beefa0cb1d 100644 --- a/tests/Tracing/PropagationContextTest.php +++ b/tests/Tracing/PropagationContextTest.php @@ -14,7 +14,7 @@ final class PropagationContextTest extends TestCase { - public function testFromDefaults() + public function testFromDefaults(): void { $propagationContext = PropagationContext::fromDefaults(); @@ -27,7 +27,7 @@ public function testFromDefaults() /** * @dataProvider tracingDataProvider */ - public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen): void { $propagationContext = PropagationContext::fromHeaders($sentryTraceHeader, $baggageHeader); @@ -49,7 +49,7 @@ public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader /** * @dataProvider tracingDataProvider */ - public function testFromEnvironment(string $sentryTrace, string $baggage, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromEnvironment(string $sentryTrace, string $baggage, ?TraceId $expectedTraceId, ?SpanId $expectedParentSpanId, ?bool $expectedDynamicSamplingContextFrozen): void { $propagationContext = PropagationContext::fromEnvironment($sentryTrace, $baggage); @@ -95,7 +95,7 @@ public static function tracingDataProvider(): iterable ]; } - public function testToTraceparent() + public function testToTraceparent(): void { $propagationContext = PropagationContext::fromDefaults(); $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); @@ -104,7 +104,7 @@ public function testToTraceparent() $this->assertSame('566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', $propagationContext->toTraceparent()); } - public function testToBaggage() + public function testToBaggage(): void { $dynamicSamplingContext = DynamicSamplingContext::fromHeader('sentry-trace_id=566e3688a61d4bc888951642d6f14a19'); $propagationContext = PropagationContext::fromDefaults(); @@ -113,7 +113,7 @@ public function testToBaggage() $this->assertSame('sentry-trace_id=566e3688a61d4bc888951642d6f14a19', $propagationContext->toBaggage()); } - public function testGetTraceContext() + public function testGetTraceContext(): void { $propagationContext = PropagationContext::fromDefaults(); $propagationContext->setTraceId(new TraceId('566e3688a61d4bc888951642d6f14a19')); @@ -136,6 +136,41 @@ public function testGetTraceContext() ], $propagationContext->getTraceContext()); } + /** + * @dataProvider invalidSampleRandDataProvider + */ + public function testInvalidSampleRandIsIgnored(string $sampleRand): void + { + $propagationContext = PropagationContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-sample_rate=0.4,sentry-sample_rand=' . rawurlencode($sampleRand) + ); + + $generatedSampleRand = $propagationContext->getSampleRand(); + + $this->assertNotNull($generatedSampleRand); + $this->assertGreaterThanOrEqual(0.0, $generatedSampleRand); + $this->assertLessThan(0.4, $generatedSampleRand); + } + + public function testSampleRandIsIgnoredWithoutSentryTraceHeader(): void + { + $propagationContext = PropagationContext::fromHeaders('', 'sentry-sample_rand=-1.0'); + $sampleRand = $propagationContext->getSampleRand(); + + $this->assertNotNull($sampleRand); + $this->assertGreaterThanOrEqual(0.0, $sampleRand); + $this->assertLessThanOrEqual(1.0, $sampleRand); + } + + public static function invalidSampleRandDataProvider(): iterable + { + yield ['-1.0']; + yield ['1']; + yield ['2.0']; + yield ['foo']; + } + public function testSampleRandRangeWhenParentNotSampledAndSampleRateProvided(): void { $propagationContext = PropagationContext::fromHeaders( diff --git a/tests/Tracing/TransactionContextTest.php b/tests/Tracing/TransactionContextTest.php index 3f382d39fd..678d8d8979 100644 --- a/tests/Tracing/TransactionContextTest.php +++ b/tests/Tracing/TransactionContextTest.php @@ -5,6 +5,12 @@ namespace Sentry\Tests\Tracing; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Sentry\ClientInterface; +use Sentry\NoOpClient; +use Sentry\Options; +use Sentry\SentrySdk; +use Sentry\State\Hub; use Sentry\Tracing\DynamicSamplingContext; use Sentry\Tracing\SpanId; use Sentry\Tracing\TraceId; @@ -37,7 +43,7 @@ public function testGettersAndSetters(): void /** * @dataProvider tracingDataProvider */ - public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen): void { $spanContext = TransactionContext::fromHeaders($sentryTraceHeader, $baggageHeader); @@ -51,7 +57,7 @@ public function testFromHeaders(string $sentryTraceHeader, string $baggageHeader /** * @dataProvider tracingDataProvider */ - public function testFromEnvironment(string $sentryTrace, string $baggage, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen) + public function testFromEnvironment(string $sentryTrace, string $baggage, ?SpanId $expectedSpanId, ?TraceId $expectedTraceId, ?bool $expectedParentSampled, ?string $expectedDynamicSamplingContextClass, ?bool $expectedDynamicSamplingContextFrozen): void { $spanContext = TransactionContext::fromEnvironment($sentryTrace, $baggage); @@ -135,6 +141,67 @@ public static function tracingDataProvider(): iterable ]; } + /** + * @dataProvider invalidSampleRandDataProvider + */ + public function testInvalidSampleRandIsIgnored(string $sampleRand): void + { + $context = TransactionContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8-1', + 'sentry-sample_rate=0.4,sentry-sample_rand=' . rawurlencode($sampleRand) + ); + + $generatedSampleRand = $context->getMetadata()->getSampleRand(); + + $this->assertNotNull($generatedSampleRand); + $this->assertGreaterThanOrEqual(0.0, $generatedSampleRand); + $this->assertLessThan(0.4, $generatedSampleRand); + } + + public function testSampleRandIsIgnoredWithoutSentryTraceHeader(): void + { + $context = TransactionContext::fromHeaders('', 'sentry-sample_rand=-1.0'); + $sampleRand = $context->getMetadata()->getSampleRand(); + + $this->assertNotNull($sampleRand); + $this->assertGreaterThanOrEqual(0.0, $sampleRand); + $this->assertLessThanOrEqual(1.0, $sampleRand); + } + + public function testInvalidSampleRandIsLogged(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('debug') + ->with( + $this->stringContains('Ignoring invalid sentry-sample_rand baggage value'), + ['sample_rand' => '-1.0'] + ); + + $client = $this->createMock(ClientInterface::class); + $client->method('getOptions') + ->willReturn(new Options(['logger' => $logger])); + + SentrySdk::setCurrentHub(new Hub($client)); + + try { + TransactionContext::fromHeaders( + '566e3688a61d4bc888951642d6f14a19-566e3688a61d4bc8', + 'sentry-sample_rand=-1.0' + ); + } finally { + SentrySdk::setCurrentHub(new Hub(new NoOpClient())); + } + } + + public static function invalidSampleRandDataProvider(): iterable + { + yield ['-1.0']; + yield ['1']; + yield ['2.0']; + yield ['foo']; + } + public function testSampleRandRangeWhenParentNotSampledAndSampleRateProvided(): void { $context = TransactionContext::fromHeaders( diff --git a/tests/Util/HttpTest.php b/tests/Util/HttpTest.php index 342ad1b753..c4a390a4f8 100644 --- a/tests/Util/HttpTest.php +++ b/tests/Util/HttpTest.php @@ -10,6 +10,16 @@ final class HttpTest extends TestCase { + public function testGetSentryAuthHeader(): void + { + $dsn = Dsn::createFromString('http://public@example.com/1'); + + $this->assertSame( + 'Sentry sentry_version=7, sentry_client=sentry.sdk.identifier/1.2.3, sentry_key=public', + Http::getSentryAuthHeader($dsn, 'sentry.sdk.identifier', '1.2.3') + ); + } + /** * @dataProvider getRequestHeadersDataProvider */ @@ -26,7 +36,11 @@ public static function getRequestHeadersDataProvider(): \Generator '1.2.3', [ 'Content-Type: application/x-sentry-envelope', - 'X-Sentry-Auth: Sentry sentry_version=7, sentry_client=sentry.sdk.identifier/1.2.3, sentry_key=public', + 'X-Sentry-Auth: ' . Http::getSentryAuthHeader( + Dsn::createFromString('http://public@example.com/1'), + 'sentry.sdk.identifier', + '1.2.3' + ), ], ]; } diff --git a/tests/Util/TelemetryStorageTest.php b/tests/Util/TelemetryStorageTest.php new file mode 100644 index 0000000000..82571e89b4 --- /dev/null +++ b/tests/Util/TelemetryStorageTest.php @@ -0,0 +1,78 @@ +push('foo'); + $storage->push('bar'); + + $result = $storage->toArray(); + $this->assertSame(2, $storage->count()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testUnboundedDrainClearsStorage(): void + { + $storage = TelemetryStorage::unbounded(); + $storage->push('foo'); + $storage->push('bar'); + + $this->assertSame(2, $storage->count()); + $result = $storage->drain(); + $this->assertTrue($storage->isEmpty()); + $this->assertEquals(['foo', 'bar'], $result); + } + + public function testUnboundedIsEmpty(): void + { + $storage = TelemetryStorage::unbounded(); + $this->assertTrue($storage->isEmpty()); + + $storage->push('foo'); + + $this->assertFalse($storage->isEmpty()); + } + + public function testBoundedCapacityOverwritesOldestItems(): void + { + $storage = TelemetryStorage::bounded(2); + $storage->push('foo'); + $storage->push('bar'); + $storage->push('baz'); + + $this->assertSame(2, $storage->count()); + $this->assertEquals(['bar', 'baz'], $storage->toArray()); + } + + public function testBoundedDrainReturnsLogicalOrderAndClearsStorage(): void + { + $storage = TelemetryStorage::bounded(2); + $storage->push('foo'); + $storage->push('bar'); + $storage->push('baz'); + + $this->assertSame(2, $storage->count()); + $result = $storage->drain(); + $this->assertTrue($storage->isEmpty()); + $this->assertEquals(['bar', 'baz'], $result); + } + + public function testBoundedCapacityOneKeepsLatestItem(): void + { + $storage = TelemetryStorage::bounded(1); + $storage->push('foo'); + $storage->push('bar'); + + $this->assertCount(1, $storage); + $this->assertEquals(['bar'], $storage->toArray()); + } +} diff --git a/tests/phpt/error_handler_does_not_capture_memory_limit_increase_warning_during_out_of_memory_handling.phpt b/tests/phpt/error_handler_does_not_capture_memory_limit_increase_warning_during_out_of_memory_handling.phpt new file mode 100644 index 0000000000..7040842784 --- /dev/null +++ b/tests/phpt/error_handler_does_not_capture_memory_limit_increase_warning_during_out_of_memory_handling.phpt @@ -0,0 +1,73 @@ +--TEST-- +Test that OOM handling does not capture warnings from the memory limit increase attempt +--INI-- +memory_limit=67108864 +--FILE-- +addFatalErrorHandlerListener(static function (): void { + echo 'Fatal error listener called' . \PHP_EOL; + }); + + register_shutdown_function(static function (): void { + echo 'Memory limit increase attempts: ' . ($GLOBALS['sentry_test_ini_set_calls'] ?? 0) . \PHP_EOL; + echo 'Warning handler calls: ' . ($GLOBALS['sentry_test_warning_handler_calls'] ?? 0) . \PHP_EOL; + }); + + $foo = str_repeat('x', 1024 * 1024 * 1024); +} +?> +--EXPECTF-- +%A +Fatal error listener called +Memory limit increase attempts: 1 +Warning handler calls: 0 diff --git a/tests/phpt/error_handler_skips_impossible_memory_limit_increase_during_out_of_memory_handling.phpt b/tests/phpt/error_handler_skips_impossible_memory_limit_increase_during_out_of_memory_handling.phpt new file mode 100644 index 0000000000..7602b07c1d --- /dev/null +++ b/tests/phpt/error_handler_skips_impossible_memory_limit_increase_during_out_of_memory_handling.phpt @@ -0,0 +1,73 @@ +--TEST-- +Test that OOM handling skips the memory limit increase when current usage is already higher +--INI-- +memory_limit=67108864 +--FILE-- +addFatalErrorHandlerListener(static function (): void { + echo 'Fatal error listener called' . \PHP_EOL; + }); + + register_shutdown_function(static function (): void { + echo 'Memory limit increase attempts: ' . ($GLOBALS['sentry_test_ini_set_calls'] ?? 0) . \PHP_EOL; + echo 'Warning handler calls: ' . ($GLOBALS['sentry_test_warning_handler_calls'] ?? 0) . \PHP_EOL; + }); + + $foo = str_repeat('x', 1024 * 1024 * 1024); +} +?> +--EXPECTF-- +%A +Fatal error listener called +Memory limit increase attempts: 0 +Warning handler calls: 0