diff --git a/packages/laravel/.craft.yml b/packages/laravel/.craft.yml new file mode 100644 index 000000000000..a0b47259f28d --- /dev/null +++ b/packages/laravel/.craft.yml @@ -0,0 +1,10 @@ +minVersion: 0.23.1 +changelogPolicy: simple +preReleaseCommand: bash scripts/craft-pre-release.sh +artifactProvider: + name: none +targets: + - name: github + - name: registry + sdks: + composer:sentry/sentry-laravel: diff --git a/packages/laravel/.gitattributes b/packages/laravel/.gitattributes new file mode 100644 index 000000000000..f31d3013c276 --- /dev/null +++ b/packages/laravel/.gitattributes @@ -0,0 +1,9 @@ +/.github export-ignore +/scripts export-ignore +/test export-ignore +/.craft.yml export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php_cs export-ignore +/Makefile export-ignore +/phpunit.xml export-ignore diff --git a/packages/laravel/.gitignore b/packages/laravel/.gitignore new file mode 100644 index 000000000000..85c286a4ca60 --- /dev/null +++ b/packages/laravel/.gitignore @@ -0,0 +1,14 @@ +# IDE +.idea + +# Composer +/vendor +composer.lock + +# phpunit/phpunit +coverage.xml +.phpunit.result.cache + +# friendsofphp/php-cs-fixer +/.php_cs.cache +/.php-cs-fixer.cache diff --git a/packages/laravel/.php-cs-fixer.php b/packages/laravel/.php-cs-fixer.php new file mode 100644 index 000000000000..60761206b057 --- /dev/null +++ b/packages/laravel/.php-cs-fixer.php @@ -0,0 +1,16 @@ +in(__DIR__ . '/src') +; + +$config = new PhpCsFixer\Config; + +$config + ->setRules([ + '@PSR2' => true, + ]) + ->setFinder($finder) +; + +return $config; diff --git a/packages/laravel/CHANGELOG.md b/packages/laravel/CHANGELOG.md new file mode 100644 index 000000000000..3df6bfc650c0 --- /dev/null +++ b/packages/laravel/CHANGELOG.md @@ -0,0 +1,362 @@ +# Changelog + +## 4.8.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.8.0. + +### Bug Fixes + +- Fix `php artisan sentry:publish` mangling the .env file [(#928)](https://github.com/getsentry/sentry-laravel/pull/928) + +- Fix not (correctly) reporting transactions when using Laravel Octane [(#936)](https://github.com/getsentry/sentry-laravel/pull/936) + +### Misc + +- Improve the stacktrace of the `php artisan sentry:test` event [(#926)](https://github.com/getsentry/sentry-laravel/pull/926) + +- Remove outdated JS SDK installation step from `php artisan sentry:publish` [(#930)](https://github.com/getsentry/sentry-laravel/pull/930) + +## 4.7.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.7.1. + +### Bug Fixes + +- Always remove the `XSRF-TOKEN` cookie value before sending to Sentry [(#920)](https://github.com/getsentry/sentry-laravel/pull/920) +- Fix trace durations when using Octane [(#921)](https://github.com/getsentry/sentry-laravel/pull/921) +- Handle clousre route names [(#921)](https://github.com/getsentry/sentry-laravel/pull/921) +- Don't rely on facades when accessing the Laravel context [(#922)](https://github.com/getsentry/sentry-laravel/pull/922) +- Normalize array of cache key names before converting to string [(#923)](https://github.com/getsentry/sentry-laravel/pull/923) + +## 4.7.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.7.0. + +### Features + +- Add support for Cache Insights Module [(#914)](https://github.com/getsentry/sentry-laravel/pull/914). To learn more about this module, visit https://docs.sentry.io/product/insights/caches/. This feature requires Laravel v11.11.0 or higher. + + Cache tracing is enabled by default for new SDK installations. To enable this feature in your existing installation, update your `config/sentry.php` file with `'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),` under `'tracing'`. + +## 4.6.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.6.1. + +### Bug Fixes + +- Fix wrong queue grouping in the queue Insights Module [(#910)](https://github.com/getsentry/sentry-laravel/pull/910) + +## 4.6.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.6.0. + +### Features + +- Add support for the Queue Insights Module [(#902)](https://github.com/getsentry/sentry-laravel/pull/902). To learn more about this module, visit https://docs.sentry.io/product/performance/queue-monitoring/. + + Queue tracing is enabled by default for new SDK installations. To enable this feature in your existing installation, update your `config/sentry.php` file with `'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),` or set `SENTRY_TRACE_QUEUE_JOBS_ENABLED=true` in your environment [(#903)](https://github.com/getsentry/sentry-laravel/pull/903) + +### Bug Fixes + +- Check if a span is sampled before creating child spans [(#898)](https://github.com/getsentry/sentry-laravel/pull/898) + +- Always register the console `sentryMonitor()` macro. This fixes the macro not being available when using Laravel Lumen [(#900)](https://github.com/getsentry/sentry-laravel/pull/900) + +- Avoid manipulating the config when resolving disks [(#901)](https://github.com/getsentry/sentry-laravel/pull/901) + +### Misc + +- Various Spotlight improvements, such as the addition of a new `SENTRY_SPOTLIGHT` environment variable and not requiring a DSN to be set to use Spotlight [(#892)](https://github.com/getsentry/sentry-laravel/pull/892) + +## 4.5.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.5.1. + +### Bug Fixes + +- Fix discarded attribute violation reporter not accepting multiple property names [(#890)](https://github.com/getsentry/sentry-laravel/pull/890) + +## 4.5.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.5.0. + +### Features + +- Limit when SQL query origins are being captured [(#881)](https://github.com/getsentry/sentry-laravel/pull/881) + + We now only capture the origin of a SQL query when the query is slower than 100ms, configurable by the `SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS` environment variable. + +- Add tracing and breadcrumbs for [Notifications](https://laravel.com/docs/11.x/notifications) [(#852)](https://github.com/getsentry/sentry-laravel/pull/852) + +- Add reporter for `Model::preventAccessingMissingAttributes()` [(#824)](https://github.com/getsentry/sentry-laravel/pull/824) + +- Make it easier to enable the debug logger [(#880)](https://github.com/getsentry/sentry-laravel/pull/880) + + You can now enable the debug logger by adding the following to your `config/sentry.php` file: + + ```php + 'logger' => Sentry\Logger\DebugFileLogger::class, // This will log SDK logs to `storage_path('logs/sentry.log')` + ``` + + Only use this in development and testing environments, as it can generate a lot of logs. + +### Bug Fixes + +- Fix Lighthouse operation not detected when query contained a fragment before the operation [(#883)](https://github.com/getsentry/sentry-laravel/pull/883) + +- Fix an exception being thrown when the username extracted from the authenticated user model is not a string [(#887)](https://github.com/getsentry/sentry-laravel/pull/887) + +## 4.4.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.4.1. + +### Bug Fixes + +- Fix `assertExists`/`assertMissing` can throw on the `FilesystemDecorator` [(#877)](https://github.com/getsentry/sentry-laravel/pull/877) + +## 4.4.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.4.0. + +### Features + +- Add support for Laravel 11 Context [(#869)](https://github.com/getsentry/sentry-laravel/pull/869) + + If you are using Laravel 11 and the new "Context" capabilities we now automatically capture that context for you and it will be visible in Sentry. + Read more about the feature in the [Laravel documentation](https://laravel.com/docs/11.x/context) and how to use it. + + +## 4.3.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.3.1. + +### Bug Fixes + +- Add missing methods to `FilesystemDecorator` [(#865)](https://github.com/getsentry/sentry-laravel/pull/865) + +## 4.3.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.3.0. + +### Features + +- Add support for Laravel 11.0 [(#845)](https://github.com/getsentry/sentry-laravel/pull/845) + + If you're upgrading an existing Laravel 10 application to the new Laravel 11 directory structure, you must change how Sentry integrates into the exception handler. Update your `bootstrap/app.php` with: + + ```php + withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + // + }) + ->withExceptions(function (Exceptions $exceptions) { + Integration::handles($exceptions); + })->create(); + ``` + + If you plan to perform up-time checks against the new Laravel 11 `/up` health URL, ignore this transaction in your `config/sentry.php` file, as not doing so could consume a substantial amount of your performance unit quota. + + ```php + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-transactions + 'ignore_transactions' => [ + // Ignore Laravel's default health URL + '/up', + ], + ``` + +### Bug Fixes + +- Set `queue.publish` spans as the parent of `queue.process` spans [(#850)](https://github.com/getsentry/sentry-laravel/pull/850) + +- Consider all `http_*` SDK options from the Laravel client in the test command [(#859)](https://github.com/getsentry/sentry-laravel/pull/859) + +## 4.2.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.2.0. + +### Features + +- Add new spans, measuring the time taken to queue a job [(#833)](https://github.com/getsentry/sentry-laravel/pull/833) + +- Add support for `failure_issue_threshold` & `recovery_threshold` for `sentryMonitor()` method on scheduled commands [(#838)](https://github.com/getsentry/sentry-laravel/pull/838) + +- Automatically flush metrics when the application terminates [(#841)](https://github.com/getsentry/sentry-laravel/pull/841) + +- Add support for the W3C traceparent header [(#834)](https://github.com/getsentry/sentry-laravel/pull/834) + +- Improve `php artisan sentry:test` to show internal log messages by default [(#842)](https://github.com/getsentry/sentry-laravel/pull/842) + +## 4.1.2 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.1.2. + +### Bug Fixes + +- Fix unable to set `callable` for `integrations` option [(#826)](https://github.com/getsentry/sentry-laravel/pull/826) + +- Fix performance traces not being collected for Laravel Lumen unless missing routes are reported [(#822)](https://github.com/getsentry/sentry-laravel/pull/822) + +- Fix configuration options for queue job tracing not applying correctly [(#820)](https://github.com/getsentry/sentry-laravel/pull/820) + +### Misc + +- Allow newer versions of `symfony/psr-http-message-bridge` dependency [(#829)](https://github.com/getsentry/sentry-laravel/pull/829) + +## 4.1.1 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.1.1. + +### Bug Fixes + +- Fix missing `sentryMonitor()` macro when command is called outside the CLI environment [(#812)](https://github.com/getsentry/sentry-laravel/pull/812) + +- Don't call `terminating()` in Lumen apps below 9.1.4 [(#815)](https://github.com/getsentry/sentry-laravel/pull/815) + +## 4.1.0 + +The Sentry SDK team is happy to announce the immediate availability of Sentry Laravel SDK v4.1.0. + +### Features + +- Capture SQL query bindings (parameters) in SQL query spans [(#804)](https://github.com/getsentry/sentry-laravel/pull/804) + + To enable this feature, update your `config/sentry.php` file or set the `SENTRY_TRACE_SQL_BINDINGS_ENABLED` environment variable to `true`. + + ```php + 'tracing' => [ + 'sql_bindings' => true, + ], + ``` + +### Misc + +- Unify backtrace origin span attributes [(#803)](https://github.com/getsentry/sentry-laravel/pull/803) +- Add `ignore_exceptions` & `ignore_transactions` to default config [(#802)](https://github.com/getsentry/sentry-laravel/pull/802) + +## 4.0.0 + +The Sentry SDK team is thrilled to announce the immediate availability of Sentry Laravel SDK v4.0.0. + +### Breaking Change + +This version adds support for the underlying [Sentry PHP SDK v4.0](https://github.com/getsentry/sentry-php). +Please refer to the PHP SDK [sentry-php/UPGRADE-4.0.md](https://github.com/getsentry/sentry-php/blob/master/UPGRADE-4.0.md) guide for a complete list of breaking changes. + +- This version exclusively uses the [envelope endpoint](https://develop.sentry.dev/sdk/envelopes/) to send event data to Sentry. + + If you are using [sentry.io](https://sentry.io), no action is needed. + If you are using an on-premise/self-hosted installation of Sentry, the minimum requirement is now version `>= v20.6.0`. + +- You need to have `ext-curl` installed to use the SDK. + +- The `IgnoreErrorsIntegration` integration was removed. Use the `ignore_exceptions` option instead. + + ```php + // config/sentry.php + + 'ignore_exceptions' => [BadThingsHappenedException::class], + ``` + + This option performs an [`is_a`](https://www.php.net/manual/en/function.is-a.php) check now, so you can also ignore more generic exceptions. + +### Features + +- Enable distributed tracing for outgoing HTTP client requests [(#797)](https://github.com/getsentry/sentry-laravel/pull/797) + + This feature is only available on Laravel >= 10.14. + When making a request using the Laravel `Http` facade, we automatically attach the `sentry-trace` and `baggage` headers. + + This behaviour can be controlled by setting `trace_propagation_targets` in your `config/sentry.php` file. + + ```php + // config/sentry.php + + // All requests will contain the tracing headers. This is the default behaviour. + 'trace_propagation_targets' => null, + + // To turn this feature off completely, set the option to an empty array. + 'trace_propagation_targets' => [], + + // To only attach these headers to some requests, you can allow-list certain hosts. + 'trace_propagation_targets' => [ + 'examlpe.com', + 'api.examlpe.com', + ], + ``` + + Please make sure to remove any custom code that injected these headers previously. + If you are using the `Sentry\Tracing\GuzzleTracingMiddleware` provided by our underlying PHP SDK, you must also remove it. + +- Add support for Laravel Livewire 3 [(#798)](https://github.com/getsentry/sentry-laravel/pull/798) + + The SDK now creates traces and breadcrumbs for Livewire 3 as well. + Both the class-based and Volt usage are supported. + + ```php + // config/sentry.php + + 'breadcrumbs' => [ + // Capture Livewire components in breadcrumbs + 'livewire' => true, + ], + 'tracing' => [ + // Capture Livewire components as spans + 'livewire' => true, + ], + ``` + +- Add new fluent APIs [(#1601)](https://github.com/getsentry/sentry-php/pull/1601) + + ```php + // Before + $spanContext = new SpanContext(); + $spanContext->setDescription('myFunction'); + $spanContext->setOp('function'); + + // After + $spanContext = (new SpanContext()) + ->setDescription('myFunction'); + ->setOp('function'); + ``` + +- Simplify the breadcrumb API [(#1603)](https://github.com/getsentry/sentry-php/pull/1603) + + ```php + // Before + \Sentry\addBreadcrumb( + new \Sentry\Breadcrumb( + \Sentry\Breadcrumb::LEVEL_INFO, + \Sentry\Breadcrumb::TYPE_DEFAULT, + 'auth', // category + 'User authenticated', // message (optional) + ['user_id' => $userId] // data (optional) + ) + ); + + // After + \Sentry\addBreadcrumb( + category: 'auth', + message: 'User authenticated', // optional + metadata: ['user_id' => $userId], // optional + level: Breadcrumb::LEVEL_INFO, // set by default + type: Breadcrumb::TYPE_DEFAULT, // set by default + ); + ``` + +- New default cURL HTTP client [(#1589)](https://github.com/getsentry/sentry-php/pull/1589) + +### Misc + +- The abandoned package `php-http/message-factory` was removed. diff --git a/packages/laravel/CONTRIBUTING.md b/packages/laravel/CONTRIBUTING.md new file mode 100644 index 000000000000..9e87125a3166 --- /dev/null +++ b/packages/laravel/CONTRIBUTING.md @@ -0,0 +1,126 @@ +

+ + Sentry + +

+ +# Contributing to the Sentry SDK for Laravel + +We welcome contributions to `sentry-laravel` by the community. + +Please search the [issue tracker](https://github.com/getsentry/sentry-laravel/issues) before creating a new issue (a problem or an improvement request). Please also ask in our [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr) before submitting a new issue. There is a ton of great people in our Discord community ready to help you! + +If you feel that you can fix or implement it yourself, please read on to learn how to submit your changes. + +## Submitting changes + +- Setup the development environment. +- Clone the `sentry-laravel` repository and prepare necessary changes. +- Add tests for your changes to `tests/`. +- Run tests and make sure all of them pass. +- Submit a pull request, referencing any issues it addresses. +- Make sure to update the `CHANGELOG.md` file below the `Unreleased` heading. + +We will review your pull request as soon as possible. +Thank you for contributing! + +## Development environment + +### Requirements + +Make sure that you have PHP 7.2+ installed. Version 7.4 or higher is required to run style checkers. On macOS, we recommend using brew to install PHP. For Windows, we recommend an official PHP.net release. + +You may use [make](https://www.gnu.org/software/make) to take advantage of the provided [Makefile](Makefile). + +### Clone the repository + +```bash +git clone git@github.com:getsentry/sentry-laravel.git +``` + +### Install the dependencies + +Dependencies are managed through [Composer](https://getcomposer.org). + +```bash +composer install +``` + +### Running tests + +Tests can be run via [PHPUnit](https://phpunit.de). + +```bash +composer tests +``` + +### Static analysis + +Static analysis can be run via [PHPStan](https://phpstan.org). + +```bash +composer phpstan +``` + +### Code style + +The code is automatically formatted through [php-cs-fixer](https://cs.symfony.com). + +```bash +composer cs-fix +``` + +## Releasing a new version + +(only relevant for Sentry employees) + +Prerequisites: + +- All changes that should be released must be in the `master` branch. +- Every commit should follow the [Commit Message Format](https://develop.sentry.dev/commit-messages#commit-message-format) convention. + +Manual Process: + +- Update CHANGELOG.md with the version to be released. Example commit: https://github.com/getsentry/sentry-laravel/commit/0c0aabd4976905e279c9e49193265dd51856c219. +- On GitHub in the `sentry-laravel` repository go to "Actions" select the "Release" workflow. +- Click on "Run workflow" on the right side and make sure the `master` branch is selected. +- Set "Version to release" input field. Here you decide if it is a major, minor or patch release. (See "Versioning Policy" below) +- Click "Run Workflow" + +This will trigger [Craft](https://github.com/getsentry/craft) to prepare everything needed for a release. (For more information see [craft prepare](https://github.com/getsentry/craft#craft-prepare-preparing-a-new-release)) At the end of this process, a release issue is created in the [Publish](https://github.com/getsentry/publish) repository. (Example release issue: https://github.com/getsentry/publish/issues/815) + +Now one of the persons with release privileges (most probably your engineering manager) will review this Issue and then add the `accepted` label to the issue. + +There are always two persons involved in a release. + +If you are in a hurry and the release should be out immediately there is a Slack channel called `#proj-release-approval` where you can see your release issue and where you can ping people to please have a look immediately. + +When the release issue is labeled `accepted` [Craft](https://github.com/getsentry/craft) is triggered again to publish the release to all the right platforms. (See [craft publish](https://github.com/getsentry/craft#craft-publish-publishing-the-release) for more information). At the end of this process, the release issue on GitHub will be closed and the release is completed! Congratulations! + +There is a sequence diagram visualizing all this in the [README.md](https://github.com/getsentry/publish) of the `Publish` repository. + +### Versioning Policy + +This project follows [semver](https://semver.org), with three additions: + +- Semver says that major version `0` can include breaking changes at any time. Still, it is common practice to assume that only `0.x` releases (minor versions) can contain breaking changes while `0.x.y` releases (patch versions) are used for backwards-compatible changes (bugfixes and features). This project also follows that practice. + +- All undocumented APIs are considered internal. They are not part of this contract. + +- Certain features (e.g. integrations) may be explicitly called out as "experimental" or "unstable" in the documentation. They come with their own versioning policy described in the documentation. + +We recommend pinning your version requirements against `1.x.*` or `1.x.y`. +Either one of the following is fine: + +```json +"sentry/sentry": "^1.0", +"sentry/sentry": "^1", +``` + +A major release `N` implies the previous release `N-1` will no longer receive updates. We generally do not backport bugfixes to older versions unless they are security relevant. However, feel free to ask for backports of specific commits on the bug tracker. + +## Commit message format guidelines + +See the documentation on commit messages here: + +https://develop.sentry.dev/commit-messages/#commit-message-format diff --git a/packages/nextjs/LICENSE b/packages/laravel/LICENSE similarity index 59% rename from packages/nextjs/LICENSE rename to packages/laravel/LICENSE index 5b55ec3c5dcb..34be4e5fddb3 100644 --- a/packages/nextjs/LICENSE +++ b/packages/laravel/LICENSE @@ -1,13 +1,13 @@ MIT License -Copyright (c) 2021-2024 Functional Software, Inc. dba Sentry +Copyright (c) 2016 Functional Software, Inc. dba Sentry -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/packages/laravel/Makefile b/packages/laravel/Makefile new file mode 100644 index 000000000000..58382ed5bb13 --- /dev/null +++ b/packages/laravel/Makefile @@ -0,0 +1,29 @@ +.PHONY: develop +develop: vendor update-submodules setup-git + +vendor: composer.lock + composer install + +composer.lock: composer.json + composer update + +.PHONY: update-submodules +update-submodules: + git submodule init + git submodule update + +.PHONY: setup-git +setup-git: + git config branch.autosetuprebase always + +.PHONY: phpcs +phpcs: + composer phpcs + +.PHONY: phpstan +phpstan: + composer phpstan + +.PHONY: tests +tests: + composer tests diff --git a/packages/laravel/README.md b/packages/laravel/README.md new file mode 100644 index 000000000000..848a14b2bff3 --- /dev/null +++ b/packages/laravel/README.md @@ -0,0 +1,131 @@ +
+ + Sentry for Laravel + +
+ +_Bad software is everywhere, and we're tired of it. Sentry is on a mission to help developers write better software faster, so we can get back to enjoying technology. If you want to join us [**Check out our open positions**](https://sentry.io/careers/)_ + +# Official Sentry SDK for Laravel + +[![CI](https://github.com/getsentry/sentry-laravel/actions/workflows/ci.yaml/badge.svg)](https://github.com/getsentry/sentry-laravel/actions/workflows/ci.yaml) +[![Latest Stable Version](https://poser.pugx.org/sentry/sentry-laravel/v/stable)](https://packagist.org/packages/sentry/sentry-laravel) +[![License](https://poser.pugx.org/sentry/sentry-laravel/license)](https://packagist.org/packages/sentry/sentry-laravel) +[![Total Downloads](https://poser.pugx.org/sentry/sentry-laravel/downloads)](https://packagist.org/packages/sentry/sentry-laravel) +[![Monthly Downloads](https://poser.pugx.org/sentry/sentry-laravel/d/monthly)](https://packagist.org/packages/sentry/sentry-laravel) +[![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/cWnMQeA) + +This is the official Laravel SDK for [Sentry](https://sentry.io). + +## Getting Started + +The installation steps below work on version 11.x of the Laravel framework. + +For older Laravel versions and Lumen see: + +- [Laravel 11.x](https://docs.sentry.io/platforms/php/guides/laravel/) +- [Laravel 8.x & 9.x & 10.x](https://docs.sentry.io/platforms/php/guides/laravel/other-versions/laravel8-10/) +- [Laravel 6.x & 7.x](https://docs.sentry.io/platforms/php/guides/laravel/other-versions/laravel6-7/) +- [Laravel 5.x](https://docs.sentry.io/platforms/php/guides/laravel/other-versions/laravel5/) +- [Laravel 4.x](https://docs.sentry.io/platforms/php/guides/laravel/other-versions/laravel4/) +- [Lumen](https://docs.sentry.io/platforms/php/guides/laravel/other-versions/lumen/) + +### Install + +Install the `sentry/sentry-laravel` package: + +```bash +composer require sentry/sentry-laravel +``` + +Enable capturing unhandled exception to report to Sentry by making the following change to your `bootstrap/app.php`: + +```php {filename:bootstrap/app.php} +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + // + }) + ->withExceptions(function (Exceptions $exceptions) { + Integration::handles($exceptions); + })->create(); +``` + +> Alternatively, you can configure Sentry as a [Laravel Log Channel](https://docs.sentry.io/platforms/php/guides/laravel/usage/#log-channels), allowing you to capture `info` and `debug` logs as well. + +### Configure + +Configure the Sentry DSN with this command: + +```shell +php artisan sentry:publish --dsn=___PUBLIC_DSN___ +``` + +It creates the config file (`config/sentry.php`) and adds the `DSN` to your `.env` file. + +```shell {filename:.env} +SENTRY_LARAVEL_DSN=___PUBLIC_DSN___ +``` + +### Usage + +```php +use function Sentry\captureException; + +try { + $this->functionThatMayFail(); +} catch (\Throwable $exception) { + captureException($exception); +} +``` + +To learn more about how to use the SDK [refer to our docs](https://docs.sentry.io/platforms/php/guides/laravel/). + +## Laravel Version Compatibility + +The Laravel and Lumen versions listed below are all currently supported: + +- Laravel `>= 11.x.x` on PHP `>= 8.2` is supported starting from `4.3.0` +- Laravel `>= 10.x.x` on PHP `>= 8.1` is supported starting from `3.2.0` +- Laravel `>= 9.x.x` on PHP `>= 8.0` is supported starting from `2.11.0` +- Laravel `>= 8.x.x` on PHP `>= 7.3` is supported starting from `1.9.0` +- Laravel `>= 7.x.x` on PHP `>= 7.2` is supported starting from `1.7.0` +- Laravel `>= 6.x.x` on PHP `>= 7.2` is supported starting from `1.2.0` + +Please note that starting with version `>= 2.0.0` we require PHP Version `>= 7.2` because we are using our new [PHP SDK](https://github.com/getsentry/sentry-php) underneath. + +The Laravel versions listed below were supported in previous versions of the Sentry SDK for Laravel: + +- Laravel `<= 4.2.x` is supported until `0.8.x` +- Laravel `<= 5.7.x` on PHP `<= 7.0` is supported until `0.11.x` +- Laravel `>= 5.x.x` on PHP `>= 7.1` is supported until `2.14.x` + +## Contributing to the SDK + +Please refer to [CONTRIBUTING.md](CONTRIBUTING.md). + +## Getting Help/Support + +If you need help setting up or configuring the Laravel SDK (or anything else in the Sentry universe) please head over to the [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr). There is a ton of great people in our Discord community ready to help you! + +## Resources + +- [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/quickstart/) +- [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) +- [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](http://stackoverflow.com/questions/tagged/sentry) +- [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) + +## License + +Licensed under the MIT license, see [`LICENSE`](LICENSE). diff --git a/packages/laravel/codecov.yml b/packages/laravel/codecov.yml new file mode 100644 index 000000000000..45f5fabaff3e --- /dev/null +++ b/packages/laravel/codecov.yml @@ -0,0 +1,6 @@ +comment: false + +coverage: + status: + project: off + patch: off diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json new file mode 100644 index 000000000000..038988d583bb --- /dev/null +++ b/packages/laravel/composer.json @@ -0,0 +1,79 @@ +{ + "name": "sentry/sentry-laravel", + "type": "library", + "description": "Laravel SDK for Sentry (https://sentry.io)", + "keywords": [ + "sentry", + "laravel", + "log", + "logging", + "error-monitoring", + "error-handler", + "crash-reporting", + "crash-reports", + "profiling", + "tracing" + ], + "homepage": "https://sentry.io", + "license": "MIT", + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "require": { + "php": "^7.2 | ^8.0", + "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0", + "sentry/sentry": "^4.9", + "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0", + "nyholm/psr7": "^1.0" + }, + "autoload": { + "psr-0": { + "Sentry\\Laravel\\": "src/" + } + }, + "require-dev": { + "phpunit/phpunit": "^8.4 | ^9.3 | ^10.4", + "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0", + "livewire/livewire": "^2.0 | ^3.0", + "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0", + "friendsofphp/php-cs-fixer": "^3.11", + "mockery/mockery": "^1.3", + "phpstan/phpstan": "^1.10", + "laravel/folio": "^1.1", + "guzzlehttp/guzzle": "^7.2" + }, + "autoload-dev": { + "psr-4": { + "Sentry\\Laravel\\Tests\\": "test/Sentry/" + } + }, + "scripts": { + "check": [ + "@cs-check", + "@phpstan", + "@tests" + ], + "tests": "vendor/bin/phpunit", + "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" + }, + "extra": { + "laravel": { + "providers": [ + "Sentry\\Laravel\\ServiceProvider", + "Sentry\\Laravel\\Tracing\\ServiceProvider" + ], + "aliases": { + "Sentry": "Sentry\\Laravel\\Facade" + } + } + }, + "config": { + "sort-packages": true + }, + "prefer-stable": true +} diff --git a/packages/laravel/config/sentry.php b/packages/laravel/config/sentry.php new file mode 100644 index 000000000000..b159b0aae896 --- /dev/null +++ b/packages/laravel/config/sentry.php @@ -0,0 +1,129 @@ + env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')), + + // @see https://spotlightjs.com/ + // 'spotlight' => env('SENTRY_SPOTLIGHT', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger + // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` + + // The release version of your application + // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) + 'release' => env('SENTRY_RELEASE'), + + // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`) + 'environment' => env('SENTRY_ENVIRONMENT'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample-rate + 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate + 'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles-sample-rate + 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii + 'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-exceptions + // 'ignore_exceptions' => [], + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-transactions + 'ignore_transactions' => [ + // Ignore Laravel's default health URL + '/up', + ], + + // Breadcrumb specific configuration + 'breadcrumbs' => [ + // Capture Laravel logs as breadcrumbs + 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as breadcrumbs + 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), + + // Capture Livewire components like routes as breadcrumbs + 'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true), + + // Capture SQL queries as breadcrumbs + 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query breadcrumbs + 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + + // Capture queue job information as breadcrumbs + 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + + // Capture command information as breadcrumbs + 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + + // Capture HTTP client request information as breadcrumbs + 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + ], + + // Performance monitoring specific configuration + 'tracing' => [ + // Trace queue jobs as their own transactions (this enables tracing for queue jobs) + 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), + + // Capture queue jobs as spans when executed on the sync driver + 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), + + // Capture SQL queries as spans + 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query spans + 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + + // Capture where the SQL query originated from on the SQL query spans + 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + + // Define a threshold in milliseconds for SQL queries to resolve their origin + 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + + // Capture views rendered as spans + 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + + // Capture Livewire components as spans + 'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true), + + // Capture HTTP client requests as spans + 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as spans + 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + + // Capture Redis operations as spans (this enables Redis events in Laravel) + 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + + // Capture where the Redis command originated from on the Redis command spans + 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + + // Enable tracing for requests without a matching route (404's) + 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + + // Configures if the performance trace should continue after the response has been sent to the user until the application terminates + // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example + 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + + // Enable the tracing integrations supplied by Sentry (recommended) + 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), + ], + +]; diff --git a/packages/laravel/phpstan-baseline.neon b/packages/laravel/phpstan-baseline.neon new file mode 100644 index 000000000000..7860608a8c6d --- /dev/null +++ b/packages/laravel/phpstan-baseline.neon @@ -0,0 +1,181 @@ +parameters: + ignoreErrors: + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\RequestReceived not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\RequestTerminated not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\TaskReceived not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\TaskTerminated not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\TickReceived not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\TickTerminated not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\WorkerErrorOccurred not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Octane\\\\Events\\\\WorkerStopping not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Class Laravel\\\\Sanctum\\\\Events\\\\TokenAuthenticated not found\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneRequestReceivedHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\RequestReceived\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneRequestTerminatedHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\RequestTerminated\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneTaskReceivedHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\TaskReceived\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneTaskTerminatedHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\TaskTerminated\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneTickReceivedHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\TickReceived\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneTickTerminatedHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\TickTerminated\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneWorkerErrorOccurredHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\WorkerErrorOccurred\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:octaneWorkerStoppingHandler\\(\\) has invalid type Laravel\\\\Octane\\\\Events\\\\WorkerStopping\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$event of method Sentry\\\\Laravel\\\\EventHandler\\:\\:sanctumTokenAuthenticatedHandler\\(\\) has invalid type Laravel\\\\Sanctum\\\\Events\\\\TokenAuthenticated\\.$#" + count: 1 + path: src/Sentry/Laravel/EventHandler.php + + - + message: "#^Parameter \\$request of method Sentry\\\\Laravel\\\\Features\\\\LivewirePackageIntegration\\:\\:handleComponentBooted\\(\\) has invalid type Livewire\\\\Request\\.$#" + count: 1 + path: src/Sentry/Laravel/Features/LivewirePackageIntegration.php + + - + message: "#^Call to protected method resolve\\(\\) of class Illuminate\\\\Filesystem\\\\FilesystemManager\\.$#" + count: 1 + path: src/Sentry/Laravel/Features/Storage/Integration.php + + - + message: "#^Class Laravel\\\\Lumen\\\\Application not found\\.$#" + count: 3 + path: src/Sentry/Laravel/ServiceProvider.php + + - + message: "#^Class GraphQL\\\\Language\\\\AST\\\\DocumentNode not found\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Class GraphQL\\\\Language\\\\AST\\\\OperationDefinitionNode not found\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Class Nuwave\\\\Lighthouse\\\\Events\\\\EndExecution not found\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Class Nuwave\\\\Lighthouse\\\\Events\\\\EndRequest not found\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Class Nuwave\\\\Lighthouse\\\\Events\\\\StartExecution not found\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Class Nuwave\\\\Lighthouse\\\\Events\\\\StartRequest not found\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:extractOperationDefinitionNode\\(\\) has invalid return type GraphQL\\\\Language\\\\AST\\\\OperationDefinitionNode\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Parameter \\$endExecution of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:handleEndExecution\\(\\) has invalid type Nuwave\\\\Lighthouse\\\\Events\\\\EndExecution\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Parameter \\$endRequest of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:handleEndRequest\\(\\) has invalid type Nuwave\\\\Lighthouse\\\\Events\\\\EndRequest\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Parameter \\$operation of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:extractOperationNames\\(\\) has invalid type GraphQL\\\\Language\\\\AST\\\\OperationDefinitionNode\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Parameter \\$query of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:extractOperationDefinitionNode\\(\\) has invalid type GraphQL\\\\Language\\\\AST\\\\DocumentNode\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Parameter \\$startExecution of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:handleStartExecution\\(\\) has invalid type Nuwave\\\\Lighthouse\\\\Events\\\\StartExecution\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Parameter \\$startRequest of method Sentry\\\\Laravel\\\\Tracing\\\\Integrations\\\\LighthouseIntegration\\:\\:handleStartRequest\\(\\) has invalid type Nuwave\\\\Lighthouse\\\\Events\\\\StartRequest\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php + + - + message: "#^Class Laravel\\\\Lumen\\\\Application not found\\.$#" + count: 1 + path: src/Sentry/Laravel/Tracing/Middleware.php + + - + message: "#^Class Laravel\\\\Lumen\\\\Application not found\\.$#" + count: 2 + path: src/Sentry/Laravel/Tracing/ServiceProvider.php diff --git a/packages/laravel/phpstan.neon b/packages/laravel/phpstan.neon new file mode 100644 index 000000000000..55f28206e6fc --- /dev/null +++ b/packages/laravel/phpstan.neon @@ -0,0 +1,7 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 1 + paths: + - src diff --git a/packages/laravel/phpunit.xml b/packages/laravel/phpunit.xml new file mode 100644 index 000000000000..53b3c509d3f3 --- /dev/null +++ b/packages/laravel/phpunit.xml @@ -0,0 +1,15 @@ + + + + + ./test/Sentry/ + + + diff --git a/packages/laravel/scripts/craft-pre-release.sh b/packages/laravel/scripts/craft-pre-release.sh new file mode 100755 index 000000000000..a61e8fc59833 --- /dev/null +++ b/packages/laravel/scripts/craft-pre-release.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -eux +# Move to the project root +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd $SCRIPT_DIR +OLD_VERSION="${1}" +NEW_VERSION="${2}" + +php versionbump.php "${NEW_VERSION}" \ No newline at end of file diff --git a/packages/laravel/scripts/versionbump.php b/packages/laravel/scripts/versionbump.php new file mode 100644 index 000000000000..4183ab50f0b1 --- /dev/null +++ b/packages/laravel/scripts/versionbump.php @@ -0,0 +1,6 @@ +getUserConfig(); + + return !empty($config['dsn']); + } + + /** + * Check if Spotlight was enabled in the config. + * + * @return bool + */ + protected function hasSpotlightEnabled(): bool + { + $config = $this->getUserConfig(); + + return ($config['spotlight'] ?? false) === true; + } + + /** + * Retrieve the user configuration. + * + * @return array + */ + protected function getUserConfig(): array + { + $config = $this->app['config'][static::$abstract]; + + return empty($config) ? [] : $config; + } + + /** + * Checks if the config is set in such a way that performance tracing could be enabled. + * + * Because of `traces_sampler` being dynamic we can never be 100% confident but that is also not important. + * + * @deprecated since version 4.6. To be removed in version 5.0. + * + * @return bool + */ + protected function couldHavePerformanceTracingEnabled(): bool + { + $config = $this->getUserConfig(); + + return !empty($config['traces_sample_rate']) || !empty($config['traces_sampler']) || ($config['spotlight'] ?? false) === true; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Console/AboutCommandIntegration.php b/packages/laravel/src/Sentry/Laravel/Console/AboutCommandIntegration.php new file mode 100644 index 000000000000..1ae5ddd8b7d6 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Console/AboutCommandIntegration.php @@ -0,0 +1,51 @@ +getClient(); + + if ($client === null) { + return [ + 'Enabled' => 'NOT CONFIGURED', + 'Laravel SDK Version' => Version::SDK_VERSION, + 'PHP SDK Version' => Client::SDK_VERSION, + ]; + } + + $options = $client->getOptions(); + + // Note: order is not important since Laravel orders these alphabetically + return [ + 'Enabled' => $options->getDsn() ? 'YES' : 'MISSING DSN', + 'Environment' => $options->getEnvironment() ?: 'NOT SET', + 'Laravel SDK Version' => Version::SDK_VERSION, + 'PHP SDK Version' => Client::SDK_VERSION, + 'Release' => $options->getRelease() ?: 'NOT SET', + 'Sample Rate Errors' => $this->formatSampleRate($options->getSampleRate()), + 'Sample Rate Performance Monitoring' => $this->formatSampleRate($options->getTracesSampleRate(), $options->getTracesSampler() !== null), + 'Sample Rate Profiling' => $this->formatSampleRate($options->getProfilesSampleRate()), + 'Send Default PII' => $options->shouldSendDefaultPii() ? 'ENABLED' : 'DISABLED', + ]; + } + + private function formatSampleRate(?float $sampleRate, bool $hasSamplerCallback = false): string + { + if ($hasSamplerCallback) { + return 'CUSTOM SAMPLER'; + } + + if ($sampleRate === null) { + return 'NOT SET'; + } + + return number_format($sampleRate * 100) . '%'; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Console/PublishCommand.php b/packages/laravel/src/Sentry/Laravel/Console/PublishCommand.php new file mode 100644 index 000000000000..d51a8e7591a7 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Console/PublishCommand.php @@ -0,0 +1,163 @@ +option('dsn'); + + if (!empty($dsn) || !$this->isEnvKeySet('SENTRY_LARAVEL_DSN')) { + if (empty($dsn)) { + $dsnFromInput = $this->askForDsnInput(); + + if (empty($dsnFromInput)) { + $this->error('Please provide a valid DSN using the `--dsn` option or setting `SENTRY_LARAVEL_DSN` in your `.env` file!'); + + return 1; + } + + $dsn = $dsnFromInput; + } + + $env['SENTRY_LARAVEL_DSN'] = $dsn; + $arg['--dsn'] = $dsn; + } + + $testCommandPrompt = 'Do you want to send a test event to Sentry?'; + + if ($this->confirm('Enable Performance Monitoring?', !$this->option('without-performance-monitoring'))) { + $testCommandPrompt = 'Do you want to send a test event & transaction to Sentry?'; + + $env['SENTRY_TRACES_SAMPLE_RATE'] = '1.0'; + + $arg['--transaction'] = true; + } elseif ($this->isEnvKeySet('SENTRY_TRACES_SAMPLE_RATE')) { + $env['SENTRY_TRACES_SAMPLE_RATE'] = '0'; + } + + if ($this->confirm($testCommandPrompt, !$this->option('without-test'))) { + $testResult = $this->call('sentry:test', $arg); + + if ($testResult === 1) { + return 1; + } + } + + $this->info('Publishing Sentry config...'); + $this->call('vendor:publish', ['--provider' => ServiceProvider::class]); + + if (!$this->setEnvValues($env)) { + return 1; + } + + return 0; + } + + private function setEnvValues(array $values): bool + { + $envFilePath = app()->environmentFilePath(); + + $envFileContents = file_get_contents($envFilePath); + + if (!$envFileContents) { + $this->error('Could not read `.env` file!'); + + return false; + } + + if (count($values) > 0) { + foreach ($values as $envKey => $envValue) { + if ($this->isEnvKeySet($envKey, $envFileContents)) { + $envFileContents = preg_replace("/^{$envKey}=\"?.*?\"?(\s|$)/m", "{$envKey}={$envValue}\n", $envFileContents); + + $this->info("Updated {$envKey} with new value in your `.env` file."); + } else { + $envFileContents .= "{$envKey}={$envValue}\n"; + + $this->info("Added {$envKey} to your `.env` file."); + } + } + } + + if (!file_put_contents($envFilePath, $envFileContents)) { + $this->error('Updating the `.env` file failed!'); + + return false; + } + + return true; + } + + private function isEnvKeySet(string $envKey, ?string $envFileContents = null): bool + { + $envFileContents = $envFileContents ?? file_get_contents(app()->environmentFilePath()); + + return (bool)preg_match("/^{$envKey}=\"?.*?\"?(\s|$)/m", $envFileContents); + } + + private function askForDsnInput(): string + { + if ($this->option('no-interaction')) { + return ''; + } + + while (true) { + $this->info(''); + + $this->question('Please paste the DSN here'); + + $dsn = $this->ask('DSN'); + + // In case someone copies it with SENTRY_LARAVEL_DSN= or SENTRY_DSN= + $dsn = Str::after($dsn, '='); + + try { + Dsn::createFromString($dsn); + + return $dsn; + } catch (Exception $e) { + // Not a valid DSN do it again + $this->error('The DSN is not valid, please make sure to paste a valid DSN!'); + } + } + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Console/TestCommand.php b/packages/laravel/src/Sentry/Laravel/Console/TestCommand.php new file mode 100644 index 000000000000..120153633d05 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Console/TestCommand.php @@ -0,0 +1,257 @@ +error('You need to enable the PHP cURL extension (ext-curl).'); + + return 1; + } + + // Maximize error reporting + $old_error_reporting = error_reporting(E_ALL | E_STRICT); + + $dsn = $this->option('dsn'); + + $laravelClient = null; + + try { + $laravelClient = app(HubInterface::class)->getClient(); + } catch (Throwable $e) { + // Ignore any errors related to getting the client from the Laravel container + // These errors will surface later in the process but we should not crash here + } + + // If the DSN was not passed as option to the command we use the registered client to get the DSN from the Laravel config + if ($dsn === null) { + $dsnObject = $laravelClient === null + ? null + : $laravelClient->getOptions()->getDsn(); + + if ($dsnObject !== null) { + $dsn = (string)$dsnObject; + + $this->info('DSN discovered from Laravel config or `.env` file!'); + } + } + + // No DSN found from the command line or config + if (!$dsn) { + $this->error('Could not discover DSN!'); + + $this->printDebugTips(); + + return 1; + } + + $options = [ + 'dsn' => $dsn, + 'before_send' => static function (Event $event): ?Event { + foreach ($event->getExceptions() as $exception) { + $stacktrace = $exception->getStacktrace(); + + if ($stacktrace === null) { + continue; + } + + foreach ($stacktrace->getFrames() as $frame) { + if (str_starts_with($frame->getAbsoluteFilePath(), __DIR__)) { + $frame->setIsInApp(true); + } + } + } + + return $event; + }, + // We include this file as "in-app" so that the events generated have something to show + 'in_app_include' => [__DIR__], + 'in_app_exclude' => [base_path('artisan'), base_path('vendor')], + 'traces_sample_rate' => 1.0, + ]; + + if ($laravelClient !== null) { + // Some options are taken from the client as configured by the user + $options = array_merge($options, [ + 'release' => $laravelClient->getOptions()->getRelease(), + 'environment' => $laravelClient->getOptions()->getEnvironment(), + 'http_client' => $laravelClient->getOptions()->getHttpClient(), + 'http_proxy' => $laravelClient->getOptions()->getHttpProxy(), + 'http_proxy_authentication' => $laravelClient->getOptions()->getHttpProxyAuthentication(), + 'http_connect_timeout' => $laravelClient->getOptions()->getHttpConnectTimeout(), + 'http_timeout' => $laravelClient->getOptions()->getHttpTimeout(), + 'http_ssl_verify_peer' => $laravelClient->getOptions()->getHttpSslVerifyPeer(), + 'http_compression' => $laravelClient->getOptions()->isHttpCompressionEnabled(), + ]); + } + + try { + $clientBuilder = ClientBuilder::create($options); + } catch (Exception $e) { + $this->error($e->getMessage()); + + return 1; + } + + // Set the Laravel SDK identifier and version + $clientBuilder->setSdkIdentifier(Version::SDK_IDENTIFIER); + $clientBuilder->setSdkVersion(Version::SDK_VERSION); + + // We set a logger so we can surface errors thrown internally by the SDK + $clientBuilder->setLogger(new class($this) extends AbstractLogger { + private $command; + + public function __construct(TestCommand $command) + { + $this->command = $command; + } + + public function log($level, $message, array $context = []): void + { + // Only show debug, info and notice messages in verbose mode + $verbosity = in_array($level, ['debug', 'info', 'notice'], true) + ? OutputInterface::VERBOSITY_VERBOSE + : OutputInterface::VERBOSITY_NORMAL; + + $this->command->info("SDK({$level}): {$message}", $verbosity); + + if (in_array($level, ['error', 'critical'], true)) { + $this->command->logErrorMessageFromSDK($message); + } + } + }); + + // We create a new Hub and Client to prevent user configuration from affecting the test command + $hub = new Hub($clientBuilder->getClient()); + + $this->info('Sending test event...'); + + $exception = $this->generateTestException($this->name, ['foo' => 'bar']); + + $eventId = $hub->captureException($exception); + + if (!$eventId) { + $this->error('There was an error sending the event.'); + + $this->printDebugTips(); + + return 1; + } + + $this->info("Test event sent with ID: {$eventId}"); + + if ($this->option('transaction')) { + $this->clearErrorMessagesFromSDK(); + + $transaction = $hub->startTransaction( + TransactionContext::make() + ->setOp('sentry.test') + ->setName('Sentry Test Transaction') + ->setOrigin('auto.test.transaction') + ->setSource(TransactionSource::custom()) + ->setSampled(true) + ); + + $span = $transaction->startChild( + SpanContext::make() + ->setOp('sentry.sent') + ->setOrigin('auto.test.span') + ); + + $this->info('Sending transaction...'); + + $span->finish(); + $transactionId = $transaction->finish(); + + if (!$transactionId) { + $this->error('There was an error sending the transaction.'); + + $this->printDebugTips(); + + return 1; + } + + $this->info("Transaction sent with ID: {$transactionId}"); + } + + error_reporting($old_error_reporting); + + return 0; + } + + /** + * Generate an example exception to send to Sentry. + */ + protected function generateTestException(string $command, array $arg): Exception + { + // Do something silly + try { + throw new Exception('This is a test exception sent from the Sentry Laravel SDK.'); + } catch (Exception $exception) { + return $exception; + } + } + + public function logErrorMessageFromSDK(string $message): void + { + $this->errorMessages[] = $message; + } + + private function clearErrorMessagesFromSDK(): void + { + $this->errorMessages = []; + } + + private function printDebugTips(): void + { + $probablySSLError = false; + + foreach ($this->errorMessages as $logMessage) { + if (Str::contains($logMessage, ['SSL certificate problem', 'certificate has expired'])) { + $probablySSLError = true; + } + } + + if ($probablySSLError) { + $this->warn('The problem might be related to the Let\'s Encrypt root certificate that expired and your machine not having an up-to-date enough OpenSSL version or still having the expired root in your certificate authority store.'); + $this->warn('For more information you can check out this forum post from Let\'s Encrypt that contains helpful links on how to resolve this for your environment: https://community.letsencrypt.org/t/production-chain-changes/150739/4'); + } elseif (count($this->errorMessages) > 0) { + $this->error('Please check the error message from the SDK above for further hints about what went wrong.'); + } else { + $this->error('Please check if your DSN is set properly in your `.env` as `SENTRY_LARAVEL_DSN` or in your config file `config/sentry.php`.'); + } + } +} diff --git a/packages/laravel/src/Sentry/Laravel/EventHandler.php b/packages/laravel/src/Sentry/Laravel/EventHandler.php new file mode 100644 index 000000000000..3aeaa843daef --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/EventHandler.php @@ -0,0 +1,452 @@ + 'messageLogged', + RoutingEvents\RouteMatched::class => 'routeMatched', + DatabaseEvents\QueryExecuted::class => 'queryExecuted', + ]; + + /** + * Map authentication event handlers to events. + * + * @var array + */ + protected static $authEventHandlerMap = [ + AuthEvents\Authenticated::class => 'authenticated', + Sanctum\TokenAuthenticated::class => 'sanctumTokenAuthenticated', // Since Sanctum 2.13 + ]; + + /** + * Map Octane event handlers to events. + * + * @var array + */ + protected static $octaneEventHandlerMap = [ + Octane\RequestReceived::class => 'octaneRequestReceived', + Octane\RequestTerminated::class => 'octaneRequestTerminated', + + Octane\TaskReceived::class => 'octaneTaskReceived', + Octane\TaskTerminated::class => 'octaneTaskTerminated', + + Octane\TickReceived::class => 'octaneTickReceived', + Octane\TickTerminated::class => 'octaneTickTerminated', + + Octane\WorkerErrorOccurred::class => 'octaneWorkerErrorOccurred', + Octane\WorkerStopping::class => 'octaneWorkerStopping', + ]; + + /** + * The Laravel container. + * + * @var \Illuminate\Contracts\Container\Container + */ + private $container; + + /** + * Indicates if we should add SQL queries to the breadcrumbs. + * + * @var bool + */ + private $recordSqlQueries; + + /** + * Indicates if we should add query bindings to the breadcrumbs. + * + * @var bool + */ + private $recordSqlBindings; + + /** + * Indicates if we should add Laravel logs to the breadcrumbs. + * + * @var bool + */ + private $recordLaravelLogs; + + /** + * Indicates if we should add tick info to the breadcrumbs. + * + * @var bool + */ + private $recordOctaneTickInfo; + + /** + * Indicates if we should add task info to the breadcrumbs. + * + * @var bool + */ + private $recordOctaneTaskInfo; + + /** + * Indicates if we pushed a scope for Octane. + * + * @var bool + */ + private $pushedOctaneScope = false; + + /** + * EventHandler constructor. + * + * @param \Illuminate\Contracts\Container\Container $container + * @param array $config + */ + public function __construct(Container $container, array $config) + { + $this->container = $container; + + $this->recordSqlQueries = ($config['breadcrumbs.sql_queries'] ?? $config['breadcrumbs']['sql_queries'] ?? true) === true; + $this->recordSqlBindings = ($config['breadcrumbs.sql_bindings'] ?? $config['breadcrumbs']['sql_bindings'] ?? false) === true; + $this->recordLaravelLogs = ($config['breadcrumbs.logs'] ?? $config['breadcrumbs']['logs'] ?? true) === true; + $this->recordOctaneTickInfo = ($config['breadcrumbs.octane_tick_info'] ?? $config['breadcrumbs']['octane_tick_info'] ?? true) === true; + $this->recordOctaneTaskInfo = ($config['breadcrumbs.octane_task_info'] ?? $config['breadcrumbs']['octane_task_info'] ?? true) === true; + } + + /** + * Attach all event handlers. + */ + public function subscribe(Dispatcher $dispatcher): void + { + foreach (static::$eventHandlerMap as $eventName => $handler) { + $dispatcher->listen($eventName, [$this, $handler]); + } + } + + /** + * Attach all authentication event handlers. + */ + public function subscribeAuthEvents(Dispatcher $dispatcher): void + { + foreach (static::$authEventHandlerMap as $eventName => $handler) { + $dispatcher->listen($eventName, [$this, $handler]); + } + } + + /** + * Attach all Octane event handlers. + */ + public function subscribeOctaneEvents(Dispatcher $dispatcher): void + { + foreach (static::$octaneEventHandlerMap as $eventName => $handler) { + $dispatcher->listen($eventName, [$this, $handler]); + } + } + + /** + * Pass through the event and capture any errors. + * + * @param string $method + * @param array $arguments + */ + public function __call(string $method, array $arguments) + { + $handlerMethod = "{$method}Handler"; + + if (!method_exists($this, $handlerMethod)) { + throw new RuntimeException("Missing event handler: {$handlerMethod}"); + } + + try { + $this->{$handlerMethod}(...$arguments); + } catch (Exception $exception) { + // Ignore + } + } + + protected function routeMatchedHandler(RoutingEvents\RouteMatched $match): void + { + $routeAlias = $match->route->action['as'] ?? ''; + + // Ignore the route if it is the route for the Laravel Folio package + // We handle that route separately in the FolioPackageIntegration + if ($routeAlias === 'laravel-folio') { + return; + } + + Middleware::signalRouteWasMatched(); + + [$routeName] = Integration::extractNameAndSourceForRoute($match->route); + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_NAVIGATION, + 'route', + $routeName + )); + + Integration::setTransaction($routeName); + } + + protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): void + { + if (!$this->recordSqlQueries) { + return; + } + + $data = ['connectionName' => $query->connectionName]; + + if ($query->time !== null) { + $data['executionTimeMs'] = $query->time; + } + + if ($this->recordSqlBindings) { + $data['bindings'] = $query->bindings; + } + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'db.sql.query', + $query->sql, + $data + )); + } + + protected function messageLoggedHandler(LogEvents\MessageLogged $logEntry): void + { + if (!$this->recordLaravelLogs) { + return; + } + + // A log message with `null` as value will not be recorded by Laravel + // however empty strings are logged so we mimick that behaviour to + // check for `null` to stay consistent with how Laravel logs it + if ($logEntry->message === null) { + return; + } + + Integration::addBreadcrumb(new Breadcrumb( + $this->logLevelToBreadcrumbLevel($logEntry->level), + Breadcrumb::TYPE_DEFAULT, + 'log.' . $logEntry->level, + $logEntry->message, + $logEntry->context + )); + } + + protected function authenticatedHandler(AuthEvents\Authenticated $event): void + { + $this->configureUserScopeFromModel($event->user); + } + + protected function sanctumTokenAuthenticatedHandler(Sanctum\TokenAuthenticated $event): void + { + $this->configureUserScopeFromModel($event->token->tokenable); + } + + /** + * Configures the user scope with the user data and values from the HTTP request. + * + * @param mixed $authUser + * + * @return void + */ + private function configureUserScopeFromModel($authUser): void + { + $userData = []; + + // If the user is a Laravel Eloquent model we try to extract some common fields from it + if ($authUser instanceof Model) { + $username = $authUser->getAttribute('username'); + + $userData = [ + 'id' => $authUser instanceof Authenticatable + ? $authUser->getAuthIdentifier() + : $authUser->getKey(), + 'email' => $authUser->getAttribute('email') ?? $authUser->getAttribute('mail'), + 'username' => $username === null ? $username : (string)$username, + ]; + } + + try { + /** @var \Illuminate\Http\Request $request */ + $request = $this->container->make('request'); + + if ($request instanceof Request) { + $ipAddress = $request->ip(); + + if ($ipAddress !== null) { + $userData['ip_address'] = $ipAddress; + } + } + } catch (BindingResolutionException $e) { + // If there is no request bound we cannot get the IP address from it + } + + Integration::configureScope(static function (Scope $scope) use ($userData): void { + $scope->setUser(array_filter($userData)); + }); + } + + protected function octaneRequestReceivedHandler(Octane\RequestReceived $event): void + { + $this->prepareScopeForOctane(); + } + + protected function octaneRequestTerminatedHandler(Octane\RequestTerminated $event): void + { + $this->cleanupScopeForOctane(); + } + + protected function octaneTaskReceivedHandler(Octane\TaskReceived $event): void + { + $this->prepareScopeForOctane(); + + if (!$this->recordOctaneTaskInfo) { + return; + } + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'octane.task', + 'Processing Octane task' + )); + } + + protected function octaneTaskTerminatedHandler(Octane\TaskTerminated $event): void + { + $this->cleanupScopeForOctane(); + } + + protected function octaneTickReceivedHandler(Octane\TickReceived $event): void + { + $this->prepareScopeForOctane(); + + if (!$this->recordOctaneTickInfo) { + return; + } + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'octane.tick', + 'Processing Octane tick' + )); + } + + protected function octaneTickTerminatedHandler(Octane\TickTerminated $event): void + { + $this->cleanupScopeForOctane(); + } + + protected function octaneWorkerErrorOccurredHandler(Octane\WorkerErrorOccurred $event): void + { + $this->afterTaskWithinLongRunningProcess(); + } + + protected function octaneWorkerStoppingHandler(Octane\WorkerStopping $event): void + { + $this->afterTaskWithinLongRunningProcess(); + } + + private function prepareScopeForOctane(): void + { + $this->cleanupScopeForOctane(); + + $this->prepareScopeForTaskWithinLongRunningProcess(); + + $this->pushedOctaneScope = true; + } + + private function cleanupScopeForOctane(): void + { + $this->cleanupScopeForTaskWithinLongRunningProcessWhen($this->pushedOctaneScope); + + $this->pushedOctaneScope = false; + } + + /** + * Translates common log levels to Sentry breadcrumb levels. + * + * @param string $level Log level. Maybe any standard. + * + * @return string Breadcrumb level. + */ + private function logLevelToBreadcrumbLevel(string $level): string + { + switch (strtolower($level)) { + case 'debug': + return Breadcrumb::LEVEL_DEBUG; + case 'warning': + return Breadcrumb::LEVEL_WARNING; + case 'error': + return Breadcrumb::LEVEL_ERROR; + case 'critical': + case 'alert': + case 'emergency': + return Breadcrumb::LEVEL_FATAL; + case 'info': + case 'notice': + default: + return Breadcrumb::LEVEL_INFO; + } + } + + /** + * Should be called after a task within a long running process has ended so events can be flushed. + */ + private function afterTaskWithinLongRunningProcess(): void + { + Integration::flushEvents(); + } + + /** + * Should be called before starting a task within a long running process, this is done to prevent + * the task to have effect on the scope for the next task to run within the long running process. + */ + private function prepareScopeForTaskWithinLongRunningProcess(): void + { + SentrySdk::getCurrentHub()->pushScope(); + + // When a job starts, we want to make sure the scope is cleared of breadcrumbs + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) { + $scope->clearBreadcrumbs(); + }); + } + + /** + * Cleanup a previously prepared scope. + * + * @param bool $when Only cleanup the scope when this is true. + * + * @see prepareScopeForTaskWithinLongRunningProcess + */ + private function cleanupScopeForTaskWithinLongRunningProcessWhen(bool $when): void + { + if (!$when) { + return; + } + + $this->afterTaskWithinLongRunningProcess(); + + SentrySdk::getCurrentHub()->popScope(); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Facade.php b/packages/laravel/src/Sentry/Laravel/Facade.php new file mode 100644 index 000000000000..ad64a35b2b19 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Facade.php @@ -0,0 +1,34 @@ +isTracingFeatureEnabled('redis_commands', false) + || $this->isTracingFeatureEnabled('cache') + || $this->isBreadcrumbFeatureEnabled('cache'); + } + + public function onBoot(Dispatcher $events): void + { + if ($this->isBreadcrumbFeatureEnabled('cache')) { + $events->listen([ + Events\CacheHit::class, + Events\CacheMissed::class, + Events\KeyWritten::class, + Events\KeyForgotten::class, + ], [$this, 'handleCacheEventsForBreadcrumbs']); + } + + if ($this->isTracingFeatureEnabled('cache')) { + $events->listen([ + Events\RetrievingKey::class, + Events\RetrievingManyKeys::class, + Events\CacheHit::class, + Events\CacheMissed::class, + + Events\WritingKey::class, + Events\WritingManyKeys::class, + Events\KeyWritten::class, + Events\KeyWriteFailed::class, + + Events\ForgettingKey::class, + Events\KeyForgotten::class, + Events\KeyForgetFailed::class, + ], [$this, 'handleCacheEventsForTracing']); + } + + if ($this->isTracingFeatureEnabled('redis_commands', false)) { + $events->listen(RedisEvents\CommandExecuted::class, [$this, 'handleRedisCommands']); + + $this->container()->afterResolving(RedisManager::class, static function (RedisManager $redis): void { + $redis->enableEvents(); + }); + } + } + + public function handleCacheEventsForBreadcrumbs(Events\CacheEvent $event): void + { + switch (true) { + case $event instanceof Events\KeyWritten: + $message = 'Written'; + break; + case $event instanceof Events\KeyForgotten: + $message = 'Forgotten'; + break; + case $event instanceof Events\CacheMissed: + $message = 'Missed'; + break; + case $event instanceof Events\CacheHit: + $message = 'Read'; + break; + default: + // In case events are added in the future we do nothing when an unknown event is encountered + return; + } + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'cache', + "{$message}: {$event->key}", + $event->tags ? ['tags' => $event->tags] : [] + )); + } + + public function handleCacheEventsForTracing(Events\CacheEvent $event): void + { + if ($this->maybeHandleCacheEventAsEndOfSpan($event)) { + return; + } + + $this->withParentSpanIfSampled(function (Span $parentSpan) use ($event) { + if ($event instanceof Events\RetrievingKey || $event instanceof Events\RetrievingManyKeys) { + $keys = $this->normalizeKeyOrKeys( + $event instanceof Events\RetrievingKey + ? [$event->key] + : $event->keys + ); + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('cache.get') + ->setData([ + 'cache.key' => $keys, + ]) + ->setOrigin('auto.cache') + ->setDescription(implode(', ', $keys)) + ) + ); + } + + if ($event instanceof Events\WritingKey || $event instanceof Events\WritingManyKeys) { + $keys = $this->normalizeKeyOrKeys( + $event instanceof Events\WritingKey + ? [$event->key] + : $event->keys + ); + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('cache.put') + ->setData([ + 'cache.key' => $keys, + 'cache.ttl' => $event->seconds, + ]) + ->setOrigin('auto.cache') + ->setDescription(implode(', ', $keys)) + ) + ); + } + + if ($event instanceof Events\ForgettingKey) { + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('cache.remove') + ->setData([ + 'cache.key' => [$event->key], + ]) + ->setOrigin('auto.cache') + ->setDescription($event->key) + ) + ); + } + }); + } + + public function handleRedisCommands(RedisEvents\CommandExecuted $event): void + { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($parentSpan === null || !$parentSpan->getSampled()) { + return; + } + + $context = SpanContext::make() + ->setOp('db.redis') + ->setOrigin('auto.cache.redis'); + + $keyForDescription = ''; + + // If the first parameter is a string and does not contain a newline we use it as the description since it's most likely a key + // This is not a perfect solution but it's the best we can do without understanding the command that was executed + if (!empty($event->parameters[0]) && is_string($event->parameters[0]) && !Str::contains($event->parameters[0], "\n")) { + $keyForDescription = $event->parameters[0]; + } + + $context->setDescription(rtrim(strtoupper($event->command) . ' ' . $keyForDescription)); + $context->setStartTimestamp(microtime(true) - $event->time / 1000); + $context->setEndTimestamp($context->getStartTimestamp() + $event->time / 1000); + + $data = [ + 'db.redis.connection' => $event->connectionName, + ]; + + if ($this->shouldSendDefaultPii()) { + $data['db.redis.parameters'] = $event->parameters; + } + + if ($this->isTracingFeatureEnabled('redis_origin')) { + $commandOrigin = $this->resolveEventOrigin(); + + if ($commandOrigin !== null) { + $data = array_merge($data, $commandOrigin); + } + } + + $context->setData($data); + + $parentSpan->startChild($context); + } + + private function maybeHandleCacheEventAsEndOfSpan(Events\CacheEvent $event): bool + { + // End of span for RetrievingKey and RetrievingManyKeys events + if ($event instanceof Events\CacheHit || $event instanceof Events\CacheMissed) { + $finishedSpan = $this->maybeFinishSpan(SpanStatus::ok()); + + if ($finishedSpan !== null && count($finishedSpan->getData()['cache.key'] ?? []) === 1) { + $finishedSpan->setData([ + 'cache.hit' => $event instanceof Events\CacheHit, + ]); + } + + return true; + } + + // End of span for WritingKey and WritingManyKeys events + if ($event instanceof Events\KeyWritten || $event instanceof Events\KeyWriteFailed) { + $finishedSpan = $this->maybeFinishSpan( + $event instanceof Events\KeyWritten ? SpanStatus::ok() : SpanStatus::internalError() + ); + + if ($finishedSpan !== null) { + $finishedSpan->setData([ + 'cache.success' => $event instanceof Events\KeyWritten, + ]); + } + + return true; + } + + // End of span for ForgettingKey event + if ($event instanceof Events\KeyForgotten || $event instanceof Events\KeyForgetFailed) { + $this->maybeFinishSpan(); + + return true; + } + + return false; + } + + /** + * Normalize the array of keys to a array of only strings. + * + * @param string|string[]|array $keyOrKeys + * + * @return string[] + */ + private function normalizeKeyOrKeys($keyOrKeys): array + { + if (is_string($keyOrKeys)) { + return [$keyOrKeys]; + } + + return collect($keyOrKeys)->map(function ($value, $key) { + return is_string($key) ? $key : $value; + })->values()->all(); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php b/packages/laravel/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php new file mode 100644 index 000000000000..86a552b6a037 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Concerns/ResolvesEventOrigin.php @@ -0,0 +1,50 @@ +makeBacktraceHelper(); + + // We limit the backtrace to 20 frames to prevent too much overhead and we'd reasonable expect the origin to be within the first 20 frames + $firstAppFrame = $backtraceHelper->findFirstInAppFrameForBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20)); + + if ($firstAppFrame === null) { + return null; + } + + $filePath = $backtraceHelper->getOriginalViewPathForFrameOfCompiledViewPath($firstAppFrame) ?? $firstAppFrame->getFile(); + + return [ + 'code.filepath' => $filePath, + 'code.function' => $firstAppFrame->getFunctionName(), + 'code.lineno' => $firstAppFrame->getLine(), + ]; + } + + protected function resolveEventOriginAsString(): ?string + { + $origin = $this->resolveEventOrigin(); + + if ($origin === null) { + return null; + } + + return "{$origin['code.filepath']}:{$origin['code.lineno']}"; + } + + private function makeBacktraceHelper(): BacktraceHelper + { + return $this->container()->make(BacktraceHelper::class); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Concerns/TracksPushedScopesAndSpans.php b/packages/laravel/src/Sentry/Laravel/Features/Concerns/TracksPushedScopesAndSpans.php new file mode 100644 index 000000000000..103a5a1d2163 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Concerns/TracksPushedScopesAndSpans.php @@ -0,0 +1,93 @@ + + */ + private $parentSpanStack = []; + + /** + * Hold the stack of current spans that need to be finished still. + * + * @var array + */ + private $currentSpanStack = []; + + protected function pushSpan(Span $span): void + { + $hub = SentrySdk::getCurrentHub(); + + $this->parentSpanStack[] = $hub->getSpan(); + + $hub->setSpan($span); + + $this->currentSpanStack[] = $span; + } + + protected function pushScope(): void + { + SentrySdk::getCurrentHub()->pushScope(); + + ++$this->pushedScopeCount; + } + + protected function maybePopSpan(): ?Span + { + if (count($this->currentSpanStack) === 0) { + return null; + } + + $parent = array_pop($this->parentSpanStack); + + SentrySdk::getCurrentHub()->setSpan($parent); + + return array_pop($this->currentSpanStack); + } + + protected function maybePopScope(): void + { + Integration::flushEvents(); + + if ($this->pushedScopeCount === 0) { + return; + } + + SentrySdk::getCurrentHub()->popScope(); + + --$this->pushedScopeCount; + } + + protected function maybeFinishSpan(?SpanStatus $status = null): ?Span + { + $span = $this->maybePopSpan(); + + if ($span === null) { + return null; + } + + if ($status !== null) { + $span->setStatus($status); + } + + $span->finish(); + + return $span; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Concerns/WorksWithSpans.php b/packages/laravel/src/Sentry/Laravel/Features/Concerns/WorksWithSpans.php new file mode 100644 index 000000000000..47041640376b --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Concerns/WorksWithSpans.php @@ -0,0 +1,33 @@ +getSpan(); + + // If the span is not available or not sampled we don't need to do anything + if ($parentSpan === null || !$parentSpan->getSampled()) { + return null; + } + + return $parentSpan; + } + + /** @param callable(Span $parentSpan): void $callback */ + protected function withParentSpanIfSampled(callable $callback): void + { + $parentSpan = $this->getParentSpanIfSampled(); + + if ($parentSpan === null) { + return; + } + + $callback($parentSpan); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/ConsoleIntegration.php b/packages/laravel/src/Sentry/Laravel/Features/ConsoleIntegration.php new file mode 100644 index 000000000000..a329ee41f029 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/ConsoleIntegration.php @@ -0,0 +1,89 @@ +listen(ConsoleEvents\CommandStarting::class, [$this, 'commandStarting']); + $events->listen(ConsoleEvents\CommandFinished::class, [$this, 'commandFinished']); + } + + public function commandStarting(ConsoleEvents\CommandStarting $event): void + { + if (!$event->command) { + return; + } + + Integration::configureScope(static function (Scope $scope) use ($event): void { + $scope->setTag('command', $event->command); + }); + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'artisan.command', + 'Starting Artisan command: ' . $event->command, + [ + 'input' => $this->extractConsoleCommandInput($event->input), + ] + )); + } + } + + public function commandFinished(ConsoleEvents\CommandFinished $event): void + { + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'artisan.command', + 'Finished Artisan command: ' . $event->command, + [ + 'exit' => $event->exitCode, + 'input' => $this->extractConsoleCommandInput($event->input), + ] + )); + } + + // Flush any and all events that were possibly generated by the command + Integration::flushEvents(); + + Integration::configureScope(static function (Scope $scope): void { + $scope->removeTag('command'); + }); + } + + /** + * Extract the command input arguments if possible. + * + * @param \Symfony\Component\Console\Input\InputInterface|null $input + * + * @return string|null + */ + private function extractConsoleCommandInput(?InputInterface $input): ?string + { + if ($input instanceof ArgvInput) { + return (string)$input; + } + + return null; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/ConsoleSchedulingIntegration.php b/packages/laravel/src/Sentry/Laravel/Features/ConsoleSchedulingIntegration.php new file mode 100644 index 000000000000..6deda1fe4b46 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/ConsoleSchedulingIntegration.php @@ -0,0 +1,244 @@ + The list of checkins that are currently in progress. + */ + private $checkInStore = []; + + private $shouldHandleCheckIn = false; + + public function register(): void + { + $startCheckIn = function ( + ?string $slug, + SchedulingEvent $scheduled, + ?int $checkInMargin, + ?int $maxRuntime, + bool $updateMonitorConfig, + ?int $failureIssueThreshold, + ?int $recoveryThreshold + ) { + $this->startCheckIn( + $slug, + $scheduled, + $checkInMargin, + $maxRuntime, + $updateMonitorConfig, + $failureIssueThreshold, + $recoveryThreshold + ); + }; + $finishCheckIn = function (?string $slug, SchedulingEvent $scheduled, CheckInStatus $status) { + $this->finishCheckIn($slug, $scheduled, $status); + }; + + SchedulingEvent::macro('sentryMonitor', function ( + ?string $monitorSlug = null, + ?int $checkInMargin = null, + ?int $maxRuntime = null, + bool $updateMonitorConfig = true, + ?int $failureIssueThreshold = null, + ?int $recoveryThreshold = null + ) use ($startCheckIn, $finishCheckIn) { + /** @var SchedulingEvent $this */ + if ($monitorSlug === null && $this->command === null) { + throw new RuntimeException('The command string is null, please set a slug manually for this scheduled command using the `sentryMonitor(\'your-monitor-slug\')` macro.'); + } + + return $this + ->before(function () use ( + $startCheckIn, + $monitorSlug, + $checkInMargin, + $maxRuntime, + $updateMonitorConfig, + $failureIssueThreshold, + $recoveryThreshold + ) { + /** @var SchedulingEvent $this */ + $startCheckIn( + $monitorSlug, + $this, + $checkInMargin, + $maxRuntime, + $updateMonitorConfig, + $failureIssueThreshold, + $recoveryThreshold + ); + }) + ->onSuccess(function () use ($finishCheckIn, $monitorSlug) { + /** @var SchedulingEvent $this */ + $finishCheckIn($monitorSlug, $this, CheckInStatus::ok()); + }) + ->onFailure(function () use ($finishCheckIn, $monitorSlug) { + /** @var SchedulingEvent $this */ + $finishCheckIn($monitorSlug, $this, CheckInStatus::error()); + }); + }); + } + + public function isApplicable(): bool + { + return true; + } + + public function onBoot(): void + { + $this->shouldHandleCheckIn = true; + } + + public function onBootInactive(): void + { + $this->shouldHandleCheckIn = false; + } + + private function startCheckIn( + ?string $slug, + SchedulingEvent $scheduled, + ?int $checkInMargin, + ?int $maxRuntime, + bool $updateMonitorConfig, + ?int $failureIssueThreshold, + ?int $recoveryThreshold + ): void { + if (!$this->shouldHandleCheckIn) { + return; + } + + $checkInSlug = $slug ?? $this->makeSlugForScheduled($scheduled); + + $checkIn = $this->createCheckIn($checkInSlug, CheckInStatus::inProgress()); + + if ($updateMonitorConfig || $slug === null) { + $timezone = $scheduled->timezone; + + if ($timezone instanceof DateTimeZone) { + $timezone = $timezone->getName(); + } + + $checkIn->setMonitorConfig(new MonitorConfig( + MonitorSchedule::crontab($scheduled->getExpression()), + $checkInMargin, + $maxRuntime, + $timezone, + $failureIssueThreshold, + $recoveryThreshold + )); + } + + $cacheKey = $this->buildCacheKey($scheduled->mutexName(), $checkInSlug); + + $this->checkInStore[$cacheKey] = $checkIn; + + if ($scheduled->runInBackground) { + $this->resolveCache()->store()->put($cacheKey, $checkIn->getId(), $scheduled->expiresAt * 60); + } + + $this->sendCheckIn($checkIn); + } + + private function finishCheckIn(?string $slug, SchedulingEvent $scheduled, CheckInStatus $status): void + { + if (!$this->shouldHandleCheckIn) { + return; + } + + $mutex = $scheduled->mutexName(); + + $checkInSlug = $slug ?? $this->makeSlugForScheduled($scheduled); + + $cacheKey = $this->buildCacheKey($mutex, $checkInSlug); + + $checkIn = $this->checkInStore[$cacheKey] ?? null; + + if ($checkIn === null && $scheduled->runInBackground) { + $checkInId = $this->resolveCache()->store()->get($cacheKey); + + if ($checkInId !== null) { + $checkIn = $this->createCheckIn($checkInSlug, $status, $checkInId); + } + } + + // This should never happen (because we should always start before we finish), but better safe than sorry + if ($checkIn === null) { + return; + } + + // We don't need to keep the checkIn ID stored since we finished executing the command + unset($this->checkInStore[$mutex]); + + if ($scheduled->runInBackground) { + $this->resolveCache()->store()->forget($cacheKey); + } + + $checkIn->setStatus($status); + + $this->sendCheckIn($checkIn); + } + + private function sendCheckIn(CheckIn $checkIn): void + { + $event = SentryEvent::createCheckIn(); + $event->setCheckIn($checkIn); + + SentrySdk::getCurrentHub()->captureEvent($event); + } + + private function createCheckIn(string $slug, CheckInStatus $status, string $id = null): CheckIn + { + $options = SentrySdk::getCurrentHub()->getClient()->getOptions(); + + return new CheckIn( + $slug, + $status, + $id, + $options->getRelease(), + $options->getEnvironment() + ); + } + + private function buildCacheKey(string $mutex, string $slug): string + { + // We use the mutex name as part of the cache key to avoid collisions between the same commands with the same schedule but with different slugs + return 'sentry:checkIn:' . sha1("{$mutex}:{$slug}"); + } + + private function makeSlugForScheduled(SchedulingEvent $scheduled): string + { + $generatedSlug = Str::slug( + str_replace( + // `:` is commonly used in the command name, so we replace it with `-` to avoid it being stripped out by the slug function + ':', + '-', + trim( + // The command string always starts with the PHP binary, so we remove it since it's not relevant to the slug + Str::after($scheduled->command, ConsoleApplication::phpBinary()) + ) + ) + ); + + return "scheduled_{$generatedSlug}"; + } + + private function resolveCache(): Cache + { + return $this->container()->make(Cache::class); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Feature.php b/packages/laravel/src/Sentry/Laravel/Features/Feature.php new file mode 100644 index 000000000000..18ad156a0097 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Feature.php @@ -0,0 +1,157 @@ + + */ + private $isTracingFeatureEnabled = []; + + /** + * In-memory cache for the breadcumb feature flag. + * + * @var array + */ + private $isBreadcrumbFeatureEnabled = []; + + /** + * @param Container $container The Laravel application container. + */ + public function __construct(Container $container) + { + $this->container = $container; + } + + /** + * Indicates if the feature is applicable to the current environment. + * + * @return bool + */ + abstract public function isApplicable(): bool; + + /** + * Register the feature in the environment. + */ + public function register(): void + { + // ... + } + + /** + * Initialize the feature. + */ + public function boot(): void + { + if (method_exists($this, 'onBoot') && $this->isApplicable()) { + try { + $this->container->call([$this, 'onBoot']); + } catch (Throwable $exception) { + // If the feature setup fails, we don't want to prevent the rest of the SDK from working. + } + } + } + + /** + * Initialize the feature in an inactive state (when no DSN was set). + */ + public function bootInactive(): void + { + if (method_exists($this, 'onBootInactive') && $this->isApplicable()) { + try { + $this->container->call([$this, 'onBootInactive']); + } catch (Throwable $exception) { + // If the feature setup fails, we don't want to prevent the rest of the SDK from working. + } + } + } + + /** + * Retrieve the Laravel application container. + * + * @return Container + */ + protected function container(): Container + { + return $this->container; + } + + /** + * Retrieve the user configuration. + * + * @return array + */ + protected function getUserConfig(): array + { + $config = $this->container['config'][BaseServiceProvider::$abstract]; + + return empty($config) ? [] : $config; + } + + /** + * Should default PII be sent by default. + */ + protected function shouldSendDefaultPii(): bool + { + $client = SentrySdk::getCurrentHub()->getClient(); + + if ($client === null) { + return false; + } + + return $client->getOptions()->shouldSendDefaultPii(); + } + + /** + * Indicates if the given feature is enabled for tracing. + */ + protected function isTracingFeatureEnabled(string $feature, bool $default = true): bool + { + if (!array_key_exists($feature, $this->isTracingFeatureEnabled)) { + $this->isTracingFeatureEnabled[$feature] = $this->isFeatureEnabled('tracing', $feature, $default); + } + + return $this->isTracingFeatureEnabled[$feature]; + } + + /** + * Indicates if the given feature is enabled for breadcrumbs. + */ + protected function isBreadcrumbFeatureEnabled(string $feature, bool $default = true): bool + { + if (!array_key_exists($feature, $this->isBreadcrumbFeatureEnabled)) { + $this->isBreadcrumbFeatureEnabled[$feature] = $this->isFeatureEnabled('breadcrumbs', $feature, $default); + } + + return $this->isBreadcrumbFeatureEnabled[$feature]; + } + + /** + * Helper to test if a certain feature is enabled in the user config. + */ + private function isFeatureEnabled(string $category, string $feature, bool $default): bool + { + $config = $this->getUserConfig()[$category] ?? []; + + return ($config[$feature] ?? $default) === true; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/FolioPackageIntegration.php b/packages/laravel/src/Sentry/Laravel/Features/FolioPackageIntegration.php new file mode 100644 index 000000000000..236e81b8ecac --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/FolioPackageIntegration.php @@ -0,0 +1,62 @@ +listen(ViewMatched::class, [$this, 'handleViewMatched']); + } + + public function handleViewMatched(ViewMatched $matched): void + { + Middleware::signalRouteWasMatched(); + + $routeName = $this->extractRouteForMatchedView($matched->matchedView, $matched->mountPath); + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_NAVIGATION, + 'folio.route', + $routeName + )); + + Integration::setTransaction($routeName); + + $transaction = SentrySdk::getCurrentHub()->getTransaction(); + + if ($transaction === null || !$transaction->getSampled()) { + return; + } + + $transaction->setName($routeName); + $transaction->getMetadata()->setSource(TransactionSource::route()); + } + + private function extractRouteForMatchedView(MatchedView $matchedView, MountPath $mountPath): string + { + $path = Str::beforeLast('/' . ltrim($mountPath->baseUri . $matchedView->relativePath(), '/'), '.blade.php'); + + return Str::replace(['[', ']'], ['{', '}'], $path); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/HttpClientIntegration.php b/packages/laravel/src/Sentry/Laravel/Features/HttpClientIntegration.php new file mode 100644 index 000000000000..aa3d78b20c2d --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/HttpClientIntegration.php @@ -0,0 +1,208 @@ +isTracingFeatureEnabled(self::FEATURE_KEY) + || $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY); + } + + public function onBoot(Dispatcher $events, Factory $factory): void + { + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $events->listen(RequestSending::class, [$this, 'handleRequestSendingHandlerForTracing']); + $events->listen(ResponseReceived::class, [$this, 'handleResponseReceivedHandlerForTracing']); + $events->listen(ConnectionFailed::class, [$this, 'handleConnectionFailedHandlerForTracing']); + + // The `globalRequestMiddleware` functionality was introduced in Laravel 10.14 + if (method_exists($factory, 'globalRequestMiddleware')) { + $factory->globalRequestMiddleware([$this, 'attachTracingHeadersToRequest']); + } + } + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + $events->listen(ResponseReceived::class, [$this, 'handleResponseReceivedHandlerForBreadcrumb']); + $events->listen(ConnectionFailed::class, [$this, 'handleConnectionFailedHandlerForBreadcrumb']); + } + } + + public function attachTracingHeadersToRequest(RequestInterface $request) + { + if ($this->shouldAttachTracingHeaders($request)) { + return $request + ->withHeader('baggage', getBaggage()) + ->withHeader('sentry-trace', getTraceparent()) + ->withHeader('traceparent', getW3CTraceparent()); + } + + return $request; + } + + public function handleRequestSendingHandlerForTracing(RequestSending $event): void + { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($parentSpan === null || !$parentSpan->getSampled()) { + return; + } + + $fullUri = $this->getFullUri($event->request->url()); + $partialUri = $this->getPartialUri($fullUri); + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('http.client') + ->setData([ + 'url' => $partialUri, + // See: https://develop.sentry.dev/sdk/performance/span-data-conventions/#http + 'http.query' => $fullUri->getQuery(), + 'http.fragment' => $fullUri->getFragment(), + 'http.request.method' => $event->request->method(), + 'http.request.body.size' => $event->request->toPsrRequest()->getBody()->getSize(), + ]) + ->setOrigin('auto.http.client') + ->setDescription($event->request->method() . ' ' . $partialUri) + ) + ); + } + + public function handleResponseReceivedHandlerForTracing(ResponseReceived $event): void + { + $span = $this->maybePopSpan(); + + if ($span !== null) { + $span->setData(array_merge($span->getData(), [ + // See: https://develop.sentry.dev/sdk/performance/span-data-conventions/#http + 'http.response.status_code' => $event->response->status(), + 'http.response.body.size' => $event->response->toPsrResponse()->getBody()->getSize(), + ])); + $span->setHttpStatus($event->response->status()); + $span->finish(); + } + } + + public function handleConnectionFailedHandlerForTracing(ConnectionFailed $event): void + { + $this->maybeFinishSpan(SpanStatus::internalError()); + } + + public function handleResponseReceivedHandlerForBreadcrumb(ResponseReceived $event): void + { + $level = Breadcrumb::LEVEL_INFO; + + if ($event->response->clientError()) { + $level = Breadcrumb::LEVEL_WARNING; + } elseif ($event->response->serverError()) { + $level = Breadcrumb::LEVEL_ERROR; + } + + $fullUri = $this->getFullUri($event->request->url()); + + Integration::addBreadcrumb(new Breadcrumb( + $level, + Breadcrumb::TYPE_HTTP, + 'http', + null, + [ + 'url' => $this->getPartialUri($fullUri), + // See: https://develop.sentry.dev/sdk/performance/span-data-conventions/#http + 'http.query' => $fullUri->getQuery(), + 'http.fragment' => $fullUri->getFragment(), + 'http.request.method' => $event->request->method(), + 'http.response.status_code' => $event->response->status(), + 'http.request.body.size' => $event->request->toPsrRequest()->getBody()->getSize(), + 'http.response.body.size' => $event->response->toPsrResponse()->getBody()->getSize(), + ] + )); + } + + public function handleConnectionFailedHandlerForBreadcrumb(ConnectionFailed $event): void + { + $fullUri = $this->getFullUri($event->request->url()); + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_ERROR, + Breadcrumb::TYPE_HTTP, + 'http', + null, + [ + 'url' => $this->getPartialUri($fullUri), + // See: https://develop.sentry.dev/sdk/performance/span-data-conventions/#http + 'http.query' => $fullUri->getQuery(), + 'http.fragment' => $fullUri->getFragment(), + 'http.request.method' => $event->request->method(), + 'http.request.body.size' => $event->request->toPsrRequest()->getBody()->getSize(), + ] + )); + } + + /** + * Construct a full URI. + * + * @param string $url + * + * @return UriInterface + */ + private function getFullUri(string $url): UriInterface + { + return new Uri($url); + } + + /** + * Construct a partial URI, excluding the authority, query and fragment parts. + * + * @param UriInterface $uri + * + * @return string + */ + private function getPartialUri(UriInterface $uri): string + { + return (string)Uri::fromParts([ + 'scheme' => $uri->getScheme(), + 'host' => $uri->getHost(), + 'port' => $uri->getPort(), + 'path' => $uri->getPath(), + ]); + } + + private function shouldAttachTracingHeaders(RequestInterface $request): bool + { + $client = SentrySdk::getCurrentHub()->getClient(); + if ($client === null) { + return false; + } + + $sdkOptions = $client->getOptions(); + + // Check if the request destination is allow listed in the trace_propagation_targets option. + return $sdkOptions->getTracePropagationTargets() === null + || in_array($request->getUri()->getHost(), $sdkOptions->getTracePropagationTargets()); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/LivewirePackageIntegration.php b/packages/laravel/src/Sentry/Laravel/Features/LivewirePackageIntegration.php new file mode 100644 index 000000000000..913a6b48dc89 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/LivewirePackageIntegration.php @@ -0,0 +1,240 @@ +isTracingFeatureEnabled(self::FEATURE_KEY) + || $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY); + } + + public function onBoot(LivewireManager $livewireManager): void + { + if (class_exists(EventBus::class)) { + $this->registerLivewireThreeEventListeners($livewireManager); + + return; + } + + $this->registerLivewireTwoEventListeners($livewireManager); + } + + private function registerLivewireThreeEventListeners(LivewireManager $livewireManager): void + { + $livewireManager->listen('mount', function (Component $component, array $data) { + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $this->handleComponentBoot($component); + } + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + $this->handleComponentMount($component, $data); + } + }); + + $livewireManager->listen('hydrate', function (Component $component) { + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $this->handleComponentBoot($component); + } + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + $this->handleComponentHydrate($component); + } + }); + + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $livewireManager->listen('dehydrate', [$this, 'handleComponentDehydrate']); + } + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + $livewireManager->listen('call', [$this, 'handleComponentCall']); + } + } + + private function registerLivewireTwoEventListeners(LivewireManager $livewireManager): void + { + $livewireManager->listen('component.booted', [$this, 'handleComponentBooted']); + + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $livewireManager->listen('component.boot', function ($component) { + $this->handleComponentBoot($component); + }); + + $livewireManager->listen('component.dehydrate', [$this, 'handleComponentDehydrate']); + } + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + $livewireManager->listen('component.mount', [$this, 'handleComponentMount']); + } + } + + public function handleComponentCall(Component $component, string $method, array $arguments): void + { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'livewire', + "Component call: {$component->getName()}::{$method}", + $this->mapCallArgumentsToMethodParameters($component, $method, $arguments) ?? ['arguments' => $arguments] + )); + } + + public function handleComponentBoot(Component $component, ?string $method = null): void + { + if ($this->isLivewireRequest()) { + $this->updateTransactionName($component->getName()); + } + + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($parentSpan === null || !$parentSpan->getSampled()) { + return; + } + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('ui.livewire.component') + ->setOrigin('auto.laravel.livewire') + ->setDescription( + empty($method) + ? $component->getName() + : "{$component->getName()}::{$method}" + ) + ) + ); + } + + public function handleComponentMount(Component $component, array $data): void + { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'livewire', + "Component mount: {$component->getName()}", + $data + )); + } + + public function handleComponentBooted(Component $component, Request $request): void + { + if (!$this->isLivewireRequest()) { + return; + } + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'livewire', + "Component booted: {$component->getName()}", + ['updates' => $request->updates] + )); + } + + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $this->updateTransactionName($component->getName()); + } + } + + public function handleComponentHydrate(Component $component): void + { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'livewire', + "Component hydrate: {$component->getName()}", + $component->all() + )); + } + + public function handleComponentDehydrate(Component $component): void + { + $span = $this->maybeFinishSpan(); + } + + private function updateTransactionName(string $componentName): void + { + $transaction = SentrySdk::getCurrentHub()->getTransaction(); + + if ($transaction === null) { + return; + } + + $transactionName = "livewire?component={$componentName}"; + + $transaction->setName($transactionName); + $transaction->getMetadata()->setSource(TransactionSource::custom()); + + Integration::setTransaction($transactionName); + } + + private function isLivewireRequest(): bool + { + try { + /** @var \Illuminate\Http\Request $request */ + $request = $this->container()->make('request'); + + if ($request === null) { + return false; + } + + return $request->hasHeader('x-livewire'); + } catch (\Throwable $e) { + // If the request cannot be resolved, it's probably not a Livewire request. + return false; + } + } + + private function mapCallArgumentsToMethodParameters(Component $component, string $method, array $data): ?array + { + // If the data is empty there is nothing to do and we can return early + // We also do a quick sanity check the method exists to prevent doing more expensive reflection to come to the same conclusion + if (empty($data) || !method_exists($component, $method)) { + return null; + } + + try { + $reflection = new \ReflectionMethod($component, $method); + $parameters = []; + + foreach ($reflection->getParameters() as $parameter) { + $defaultValue = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : ''; + + $parameters["\${$parameter->getName()}"] = $data[$parameter->getPosition()] ?? $defaultValue; + + unset($data[$parameter->getPosition()]); + } + + if (!empty($data)) { + $parameters['additionalArguments'] = $data; + } + + return $parameters; + } catch (\ReflectionException $e) { + // If reflection fails, fail the mapping instead of crashing + return null; + } + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/LogIntegration.php b/packages/laravel/src/Sentry/Laravel/Features/LogIntegration.php new file mode 100644 index 000000000000..af4fb728d082 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/LogIntegration.php @@ -0,0 +1,21 @@ +isTracingFeatureEnabled(self::FEATURE_KEY) + || $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY); + } + + public function onBoot(Dispatcher $events): void + { + if ($this->isTracingFeatureEnabled(self::FEATURE_KEY)) { + $events->listen(NotificationSending::class, [$this, 'handleNotificationSending']); + } + + $events->listen(NotificationSent::class, [$this, 'handleNotificationSent']); + } + + public function handleNotificationSending(NotificationSending $event): void + { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($parentSpan === null || !$parentSpan->getSampled()) { + return; + } + + $context = SpanContext::make() + ->setOp('notification.send') + ->setData([ + 'id' => $event->notification->id, + 'channel' => $event->channel, + 'notifiable' => $this->formatNotifiable($event->notifiable), + 'notification' => get_class($event->notification), + ]) + ->setOrigin('auto.laravel.notifications') + ->setDescription($event->channel); + + $this->pushSpan($parentSpan->startChild($context)); + } + + public function handleNotificationSent(NotificationSent $event): void + { + $this->maybeFinishSpan(SpanStatus::ok()); + + if ($this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY)) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'notification.sent', + 'Sent notification', + [ + 'channel' => $event->channel, + 'notifiable' => $this->formatNotifiable($event->notifiable), + 'notification' => get_class($event->notification), + ] + )); + } + } + + private function formatNotifiable(object $notifiable): string + { + $notifiable = get_class($notifiable); + + if ($notifiable instanceof Model) { + $notifiable .= "({$notifiable->getKey()})"; + } + + return $notifiable; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/QueueIntegration.php b/packages/laravel/src/Sentry/Laravel/Features/QueueIntegration.php new file mode 100644 index 000000000000..0085d11cf553 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/QueueIntegration.php @@ -0,0 +1,256 @@ +container()->bound('queue')) { + return false; + } + + return $this->isBreadcrumbFeatureEnabled('queue_info') + || $this->isTracingFeatureEnabled('queue_jobs') + || $this->isTracingFeatureEnabled('queue_job_transactions'); + } + + public function onBoot(Dispatcher $events): void + { + $events->listen(JobQueueing::class, [$this, 'handleJobQueueingEvent']); + $events->listen(JobQueued::class, [$this, 'handleJobQueuedEvent']); + + $events->listen(JobProcessed::class, [$this, 'handleJobProcessedQueueEvent']); + $events->listen(JobProcessing::class, [$this, 'handleJobProcessingQueueEvent']); + $events->listen(WorkerStopping::class, [$this, 'handleWorkerStoppingQueueEvent']); + $events->listen(JobExceptionOccurred::class, [$this, 'handleJobExceptionOccurredQueueEvent']); + + if ($this->isTracingFeatureEnabled('queue_jobs') || $this->isTracingFeatureEnabled('queue_job_transactions')) { + Queue::createPayloadUsing(function (?string $connection, ?string $queue, ?array $payload): ?array { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + if ($parentSpan !== null && $parentSpan->getSampled()) { + $context = (new SpanContext) + ->setOp(self::QUEUE_SPAN_OP_QUEUE_PUBLISH) + ->setData([ + 'messaging.system' => 'laravel', + 'messaging.message.id' => $payload['uuid'] ?? null, + 'messaging.destination.name' => $this->normalizeQueueName($queue), + 'messaging.destination.connection' => $connection, + ]) + ->setDescription($queue); + + $this->pushSpan($parentSpan->startChild($context)); + } + + if ($payload !== null) { + $payload[self::QUEUE_PAYLOAD_BAGGAGE_DATA] = getBaggage(); + $payload[self::QUEUE_PAYLOAD_TRACE_PARENT_DATA] = getTraceparent(); + $payload[self::QUEUE_PAYLOAD_PUBLISH_TIME] = microtime(true); + } + + return $payload; + }); + } + } + + public function handleJobQueueingEvent(JobQueueing $event): void + { + $currentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no tracing span active there is no need to handle the event + if ($currentSpan === null || $currentSpan->getOp() !== self::QUEUE_SPAN_OP_QUEUE_PUBLISH) { + return; + } + + $jobName = $event->job; + + if ($jobName instanceof Closure) { + $jobName = 'Closure'; + } elseif (is_object($jobName)) { + $jobName = get_class($jobName); + } + + $currentSpan + ->setDescription($jobName); + } + + public function handleJobQueuedEvent(JobQueued $event): void + { + $this->maybeFinishSpan(); + } + + public function handleJobProcessedQueueEvent(JobProcessed $event): void + { + $this->maybeFinishSpan(SpanStatus::ok()); + + $this->maybePopScope(); + } + + public function handleJobProcessingQueueEvent(JobProcessing $event): void + { + $this->maybePopScope(); + + $this->pushScope(); + + if ($this->isBreadcrumbFeatureEnabled('queue_info')) { + $job = [ + 'job' => $event->job->getName(), + 'queue' => $event->job->getQueue(), + 'attempts' => $event->job->attempts(), + 'connection' => $event->connectionName, + ]; + + // Resolve name exists only from Laravel 5.3+ + if (method_exists($event->job, 'resolveName')) { + $job['resolved'] = $event->job->resolveName(); + } + + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + 'queue.job', + 'Processing queue job', + $job + )); + } + + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no tracing span active and we don't trace jobs as transactions there is no need to handle the event + if ($parentSpan === null && !$this->isTracingFeatureEnabled('queue_job_transactions')) { + return; + } + + // If there is a parent span we can record the job as a child unless the parent is not sample or we are configured to not do so + if ($parentSpan !== null && (!$parentSpan->getSampled() || !$this->isTracingFeatureEnabled('queue_jobs'))) { + return; + } + + $jobPayload = $event->job->payload(); + + if ($parentSpan === null) { + $baggage = $jobPayload[self::QUEUE_PAYLOAD_BAGGAGE_DATA] ?? null; + $traceParent = $jobPayload[self::QUEUE_PAYLOAD_TRACE_PARENT_DATA] ?? null; + + $context = continueTrace($traceParent ?? '', $baggage ?? ''); + + // If the parent transaction was not sampled we also stop the queue job from being recorded + if ($context->getParentSampled() === false) { + return; + } + } else { + $context = new SpanContext; + } + + $resolvedJobName = $event->job->resolveName(); + + $jobPublishedAt = $jobPayload[self::QUEUE_PAYLOAD_PUBLISH_TIME] ?? null; + + $job = [ + 'messaging.system' => 'laravel', + + 'messaging.destination.name' => $this->normalizeQueueName($event->job->getQueue()), + 'messaging.destination.connection' => $event->connectionName, + + 'messaging.message.id' => $jobPayload['uuid'] ?? null, + 'messaging.message.envelope.size' => strlen($event->job->getRawBody()), + 'messaging.message.body.size' => strlen(json_encode($jobPayload['data'] ?? [])), + 'messaging.message.retry.count' => $event->job->attempts(), + 'messaging.message.receive.latency' => $jobPublishedAt !== null ? microtime(true) - $jobPublishedAt : null, + ]; + + if ($context instanceof TransactionContext) { + $context->setName($resolvedJobName); + $context->setSource(TransactionSource::task()); + } + + $context->setOp('queue.process'); + $context->setData($job); + $context->setOrigin('auto.queue'); + $context->setStartTimestamp(microtime(true)); + + // When the parent span is null we start a new transaction otherwise we start a child of the current span + if ($parentSpan === null) { + $span = SentrySdk::getCurrentHub()->startTransaction($context); + } else { + $span = $parentSpan->startChild($context); + } + + $this->pushSpan($span); + } + + public function handleWorkerStoppingQueueEvent(WorkerStopping $event): void + { + Integration::flushEvents(); + } + + public function handleJobExceptionOccurredQueueEvent(JobExceptionOccurred $event): void + { + $this->maybeFinishSpan(SpanStatus::internalError()); + + Integration::flushEvents(); + } + + private function normalizeQueueName(?string $queue): string + { + if ($queue === null) { + return ''; + } + + // SQS queues are sometimes formatted like: https://sqs..amazonaws.com// + if (filter_var($queue, FILTER_VALIDATE_URL) !== false) { + return Str::afterLast($queue, '/'); + } + + // Jobs pushed onto the Redis driver are formatted as queues: + return Str::after($queue, 'queues:'); + } + + protected function pushScope(): void + { + $this->pushScopeTrait(); + + // When a job starts, we want to make sure the scope is cleared of breadcrumbs + // as well as setting a new propagation context. + SentrySdk::getCurrentHub()->configureScope(static function (Scope $scope) { + $scope->clearBreadcrumbs(); + $scope->setPropagationContext(PropagationContext::fromDefaults()); + }); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/CloudFilesystemDecorator.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/CloudFilesystemDecorator.php new file mode 100644 index 000000000000..3be8dd4d0089 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/CloudFilesystemDecorator.php @@ -0,0 +1,13 @@ +withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/FilesystemAdapterDecorator.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/FilesystemAdapterDecorator.php new file mode 100644 index 000000000000..6414a9e5a330 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/FilesystemAdapterDecorator.php @@ -0,0 +1,37 @@ +getDescriptionAndDataForPathOrPaths($path); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, $data); + } + + public function assertMissing($path) + { + [$description, $data] = $this->getDescriptionAndDataForPathOrPaths($path); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, $data); + } + + public function assertDirectoryEmpty($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function temporaryUrl($path, $expiration, array $options = []) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'expiration', 'options')); + } + + public function temporaryUploadUrl($path, $expiration, array $options = []) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'expiration', 'options')); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/FilesystemDecorator.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/FilesystemDecorator.php new file mode 100644 index 000000000000..7c1301795407 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/FilesystemDecorator.php @@ -0,0 +1,212 @@ + $args + * @param array $data + * + * @return mixed + */ + protected function withSentry(string $method, array $args, ?string $description, array $data) + { + $op = "file.{$method}"; // See https://develop.sentry.dev/sdk/performance/span-operations/#web-server + $data = array_merge($data, $this->defaultData); + + if ($this->recordBreadcrumbs) { + Integration::addBreadcrumb(new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::TYPE_DEFAULT, + $op, + $description, + $data + )); + } + + if ($this->recordSpans) { + return trace( + function () use ($method, $args) { + return $this->filesystem->{$method}(...$args); + }, + SpanContext::make() + ->setOp($op) + ->setData($data) + ->setOrigin('auto.filesystem') + ->setDescription($description) + ); + } + + return $this->filesystem->{$method}(...$args); + } + + public function path($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function exists($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function get($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function readStream($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function put($path, $contents, $options = []) + { + $description = is_string($contents) ? sprintf('%s (%s)', $path, Filesize::toHuman(strlen($contents))) : $path; + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path', 'options')); + } + + public function putFile($path, $file = null, $options = []) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'file', 'options')); + } + + public function putFileAs($path, $file, $name = null, $options = []) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'file', 'name', 'options')); + } + + public function writeStream($path, $resource, array $options = []) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'options')); + } + + public function getVisibility($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function setVisibility($path, $visibility) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path', 'visibility')); + } + + public function prepend($path, $data, $separator = PHP_EOL) + { + $description = is_string($data) ? sprintf('%s (%s)', $path, Filesize::toHuman(strlen($data))) : $path; + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path')); + } + + public function append($path, $data, $separator = PHP_EOL) + { + $description = is_string($data) ? sprintf('%s (%s)', $path, Filesize::toHuman(strlen($data))) : $path; + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, compact('path')); + } + + public function delete($paths) + { + [$description, $data] = $this->getDescriptionAndDataForPathOrPaths($paths); + + return $this->withSentry(__FUNCTION__, func_get_args(), $description, $data); + } + + public function copy($from, $to) + { + return $this->withSentry(__FUNCTION__, func_get_args(), sprintf('from "%s" to "%s"', $from, $to), compact('from', 'to')); + } + + public function move($from, $to) + { + return $this->withSentry(__FUNCTION__, func_get_args(), sprintf('from "%s" to "%s"', $from, $to), compact('from', 'to')); + } + + public function size($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function lastModified($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function files($directory = null, $recursive = false) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory', 'recursive')); + } + + public function allFiles($directory = null) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory')); + } + + public function directories($directory = null, $recursive = false) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory', 'recursive')); + } + + public function allDirectories($directory = null) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory')); + } + + public function makeDirectory($path) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $path, compact('path')); + } + + public function deleteDirectory($directory) + { + return $this->withSentry(__FUNCTION__, func_get_args(), $directory, compact('directory')); + } + + public function __call($name, $arguments) + { + return $this->filesystem->{$name}(...$arguments); + } + + protected function getDescriptionAndDataForPathOrPaths($pathOrPaths): array + { + if (is_array($pathOrPaths)) { + $description = sprintf('%s paths', count($pathOrPaths)); + $data = ['paths' => $pathOrPaths]; + } else { + $description = $pathOrPaths; + $data = ['path' => $pathOrPaths]; + } + + return [$description, $data]; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/Integration.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/Integration.php new file mode 100644 index 000000000000..e2c654555b7e --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/Integration.php @@ -0,0 +1,140 @@ +container()->afterResolving(FilesystemManager::class, function (FilesystemManager $filesystemManager): void { + $filesystemManager->extend( + self::STORAGE_DRIVER_NAME, + function (Application $application, array $config) use ($filesystemManager): Filesystem { + if (empty($config['sentry_disk_name'])) { + throw new RuntimeException(sprintf('Missing `sentry_disk_name` config key for `%s` filesystem driver.', self::STORAGE_DRIVER_NAME)); + } + + if (empty($config['sentry_original_driver'])) { + throw new RuntimeException(sprintf('Missing `sentry_original_driver` config key for `%s` filesystem driver.', self::STORAGE_DRIVER_NAME)); + } + + if ($config['sentry_original_driver'] === self::STORAGE_DRIVER_NAME) { + throw new RuntimeException(sprintf('`sentry_original_driver` for Sentry storage integration cannot be the `%s` driver.', self::STORAGE_DRIVER_NAME)); + } + + $disk = $config['sentry_disk_name']; + + $config['driver'] = $config['sentry_original_driver']; + unset($config['sentry_original_driver']); + + $diskResolver = (function (string $disk, array $config) { + // This is a "hack" to make sure that the original driver is resolved by the FilesystemManager + $oldConfig = config("filesystems.disks.{$disk}"); + + config(["filesystems.disks.{$disk}" => $config]); + + /** @var FilesystemManager $this */ + $resolved = $this->resolve($disk); + + config(["filesystems.disks.{$disk}" => $oldConfig]); + + return $resolved; + })->bindTo($filesystemManager, FilesystemManager::class); + + /** @var Filesystem $originalFilesystem */ + $originalFilesystem = $diskResolver($disk, $config); + + $defaultData = ['disk' => $disk, 'driver' => $config['driver']]; + + $recordSpans = $config['sentry_enable_spans'] ?? $this->isTracingFeatureEnabled(self::FEATURE_KEY); + $recordBreadcrumbs = $config['sentry_enable_breadcrumbs'] ?? $this->isBreadcrumbFeatureEnabled(self::FEATURE_KEY); + + if ($originalFilesystem instanceof AwsS3V3Adapter) { + return new SentryS3V3Adapter($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + + if ($originalFilesystem instanceof FilesystemAdapter) { + return new SentryFilesystemAdapter($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + + if ($originalFilesystem instanceof CloudFilesystem) { + return new SentryCloudFilesystem($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + + return new SentryFilesystem($originalFilesystem, $defaultData, $recordSpans, $recordBreadcrumbs); + } + ); + }); + } + + /** + * Decorates the configuration for a single disk with Sentry driver configuration. + + * This replaces the driver with a custom driver that will capture performance traces and breadcrumbs. + * + * The custom driver will be an instance of @see \Sentry\Laravel\Features\Storage\SentryS3V3Adapter + * if the original driver is an @see \Illuminate\Filesystem\AwsS3V3Adapter, + * and an instance of @see \Sentry\Laravel\Features\Storage\SentryFilesystemAdapter + * if the original driver is an @see \Illuminate\Filesystem\FilesystemAdapter. + * If the original driver is neither of those, it will be @see \Sentry\Laravel\Features\Storage\SentryFilesystem + * or @see \Sentry\Laravel\Features\Storage\SentryCloudFilesystem based on the contract of the original driver. + * + * You might run into problems if you expect another specific driver class. + * + * @param array $diskConfig + * + * @return array + */ + public static function configureDisk(string $diskName, array $diskConfig, bool $enableSpans = true, bool $enableBreadcrumbs = true): array + { + $currentDriver = $diskConfig['driver']; + + if ($currentDriver !== self::STORAGE_DRIVER_NAME) { + $diskConfig['driver'] = self::STORAGE_DRIVER_NAME; + $diskConfig['sentry_disk_name'] = $diskName; + $diskConfig['sentry_original_driver'] = $currentDriver; + $diskConfig['sentry_enable_spans'] = $enableSpans; + $diskConfig['sentry_enable_breadcrumbs'] = $enableBreadcrumbs; + } + + return $diskConfig; + } + + /** + * Decorates the configuration for all disks with Sentry driver configuration. + * + * @see self::configureDisk() + * + * @param array> $diskConfigs + * + * @return array> + */ + public static function configureDisks(array $diskConfigs, bool $enableSpans = true, bool $enableBreadcrumbs = true): array + { + $diskConfigsWithSentryDriver = []; + foreach ($diskConfigs as $diskName => $diskConfig) { + $diskConfigsWithSentryDriver[$diskName] = static::configureDisk($diskName, $diskConfig, $enableSpans, $enableBreadcrumbs); + } + + return $diskConfigsWithSentryDriver; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryCloudFilesystem.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryCloudFilesystem.php new file mode 100644 index 000000000000..dacd9c5d8851 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryCloudFilesystem.php @@ -0,0 +1,18 @@ +filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryFilesystem.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryFilesystem.php new file mode 100644 index 000000000000..3cef95783e5b --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryFilesystem.php @@ -0,0 +1,18 @@ +filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryFilesystemAdapter.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryFilesystemAdapter.php new file mode 100644 index 000000000000..43657000d27d --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryFilesystemAdapter.php @@ -0,0 +1,20 @@ +getDriver(), $filesystem->getAdapter(), $filesystem->getConfig()); + + $this->filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryS3V3Adapter.php b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryS3V3Adapter.php new file mode 100644 index 000000000000..80bec2ee4a99 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Features/Storage/SentryS3V3Adapter.php @@ -0,0 +1,20 @@ +getDriver(), $filesystem->getAdapter(), $filesystem->getConfig(), $filesystem->getClient()); + + $this->filesystem = $filesystem; + $this->defaultData = $defaultData; + $this->recordSpans = $recordSpans; + $this->recordBreadcrumbs = $recordBreadcrumbs; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Http/FlushEventsMiddleware.php b/packages/laravel/src/Sentry/Laravel/Http/FlushEventsMiddleware.php new file mode 100644 index 000000000000..bcc27c0b25f4 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Http/FlushEventsMiddleware.php @@ -0,0 +1,20 @@ +bound('request')) { + return null; + } + + if ($container->bound(self::CONTAINER_PSR7_INSTANCE_KEY)) { + $request = $container->make(self::CONTAINER_PSR7_INSTANCE_KEY); + } else { + $request = (new RequestFetcher)->fetchRequest(); + } + + if ($request === null) { + return null; + } + + $cookies = new Collection($request->getCookieParams()); + + // We need to filter out the cookies that are not allowed to be sent to Sentry because they are very sensitive + $forbiddenCookies = [config('session.cookie'), 'remember_*', 'XSRF-TOKEN']; + + return $request->withCookieParams( + $cookies->map(function ($value, string $key) use ($forbiddenCookies) { + if (Str::is($forbiddenCookies, $key)) { + return '[Filtered]'; + } + + return $value; + })->all() + ); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Http/SetRequestIpMiddleware.php b/packages/laravel/src/Sentry/Laravel/Http/SetRequestIpMiddleware.php new file mode 100644 index 000000000000..ca708d850a1c --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Http/SetRequestIpMiddleware.php @@ -0,0 +1,47 @@ +bound(HubInterface::class)) { + /** @var \Sentry\State\HubInterface $sentry */ + $sentry = $container->make(HubInterface::class); + + $client = $sentry->getClient(); + + if ($client !== null && $client->getOptions()->shouldSendDefaultPii()) { + $sentry->configureScope(static function (Scope $scope) use ($request): void { + $scope->setUser([ + 'ip_address' => $request->ip(), + ]); + }); + } + } + + return $next($request); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Http/SetRequestMiddleware.php b/packages/laravel/src/Sentry/Laravel/Http/SetRequestMiddleware.php new file mode 100644 index 000000000000..6cb908523d14 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Http/SetRequestMiddleware.php @@ -0,0 +1,44 @@ +bound(HubInterface::class)) { + $psrRequest = $this->resolvePsrRequest($container); + + if ($psrRequest !== null) { + $container->instance(LaravelRequestFetcher::CONTAINER_PSR7_INSTANCE_KEY, $psrRequest); + } + } + + return $next($request); + } + + private function resolvePsrRequest(Container $container): ?ServerRequestInterface + { + try { + return $container->make(ServerRequestInterface::class); + } catch (Throwable $e) { + // Do not crash if there is an exception thrown while resolving the request object + } + + return null; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Integration.php b/packages/laravel/src/Sentry/Laravel/Integration.php new file mode 100644 index 000000000000..2df00dfea0a3 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Integration.php @@ -0,0 +1,327 @@ +getIntegration(self::class); + + if (!$self instanceof self) { + return $event; + } + + if (empty($event->getTransaction())) { + $event->setTransaction(self::getTransaction()); + } + + return $event; + }); + } + + /** + * Convenience method to register the exception handler with Laravel 11.0 and up. + */ + public static function handles(Exceptions $exceptions): void + { + $exceptions->reportable(static function (Throwable $exception) { + self::captureUnhandledException($exception); + }); + } + + /** + * Adds a breadcrumb if the integration is enabled for Laravel. + * + * @param Breadcrumb $breadcrumb + */ + public static function addBreadcrumb(Breadcrumb $breadcrumb): void + { + $self = SentrySdk::getCurrentHub()->getIntegration(self::class); + + if (!$self instanceof self) { + return; + } + + addBreadcrumb($breadcrumb); + } + + /** + * Configures the scope if the integration is enabled for Laravel. + * + * @param callable $callback + */ + public static function configureScope(callable $callback): void + { + $self = SentrySdk::getCurrentHub()->getIntegration(self::class); + + if (!$self instanceof self) { + return; + } + + configureScope($callback); + } + + /** + * @return null|string + */ + public static function getTransaction(): ?string + { + return self::$transaction; + } + + /** + * @param null|string $transaction + */ + public static function setTransaction(?string $transaction): void + { + self::$transaction = $transaction; + } + + /** + * Block until all events are processed by the PHP SDK client. Also flushes metrics. + * + * @internal This is not part of the public API and is here temporarily until + * the underlying issue can be resolved, this method will be removed. + */ + public static function flushEvents(): void + { + $client = SentrySdk::getCurrentHub()->getClient(); + + if ($client !== null) { + $client->flush(); + } + + metrics()->flush(); + } + + /** + * Extract the readable name for a route and the transaction source for where that route name came from. + * + * @param \Illuminate\Routing\Route $route + * + * @return array{0: string, 1: \Sentry\Tracing\TransactionSource} + * + * @internal This helper is used in various places to extract meaningful info from a Laravel Route object. + */ + public static function extractNameAndSourceForRoute(Route $route): array + { + return [ + '/' . ltrim($route->uri(), '/'), + TransactionSource::route(), + ]; + } + + /** + * Extract the readable name for a Lumen route and the transaction source for where that route name came from. + * + * @param array $routeData The array of route data + * @param string $path The path of the request + * + * @return array{0: string, 1: \Sentry\Tracing\TransactionSource} + * + * @internal This helper is used in various places to extract meaningful info from Lumen route data. + */ + public static function extractNameAndSourceForLumenRoute(array $routeData, string $path): array + { + $routeUri = array_reduce( + array_keys($routeData[2]), + static function ($carry, $key) use ($routeData) { + $search = '/' . preg_quote($routeData[2][$key], '/') . '/'; + + // Replace the first occurrence of the route parameter value with the key name + // This is by no means a perfect solution, but it's the best we can do with the data we have + return preg_replace($search, "{{$key}}", $carry, 1); + }, + $path + ); + + return [ + '/' . ltrim($routeUri, '/'), + TransactionSource::route(), + ]; + } + + /** + * Retrieve the meta tags with tracing information to link this request to front-end requests. + * This propagates the Dynamic Sampling Context. + * + * @return string + */ + public static function sentryMeta(): string + { + return self::sentryTracingMeta() . self::sentryW3CTracingMeta() . self::sentryBaggageMeta(); + } + + /** + * Retrieve the `sentry-trace` meta tag with tracing information to link this request to front-end requests. + * + * @return string + */ + public static function sentryTracingMeta(): string + { + return sprintf('', getTraceparent()); + } + + /** + * Retrieve the `traceparent` meta tag with tracing information to link this request to front-end requests. + * + * @return string + */ + public static function sentryW3CTracingMeta(): string + { + return sprintf('', getW3CTraceparent()); + } + + /** + * Retrieve the `baggage` meta tag with information to link this request to front-end requests. + * This propagates the Dynamic Sampling Context. + * + * @return string + */ + public static function sentryBaggageMeta(): string + { + return sprintf('', getBaggage()); + } + + /** + * Capture a unhandled exception and report it to Sentry. + * + * @param \Throwable $throwable + * + * @return \Sentry\EventId|null + */ + public static function captureUnhandledException(Throwable $throwable): ?EventId + { + // We instruct users to call `captureUnhandledException` in their exception handler, however this does not mean + // the exception was actually unhandled. Laravel has the `report` helper function that is used to report to a log + // file or Sentry, but that means they are handled otherwise they wouldn't have been routed through `report`. So to + // prevent marking those as "unhandled" we try and make an educated guess if the call to `captureUnhandledException` + // came from the `report` helper and shouldn't be marked as "unhandled" even though the come to us here to be reported + $handled = self::makeAnEducatedGuessIfTheExceptionMaybeWasHandled(); + + $hint = EventHint::fromArray([ + 'mechanism' => new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, $handled), + ]); + + return SentrySdk::getCurrentHub()->captureException($throwable, $hint); + } + + /** + * Returns a callback that can be passed to `Model::handleMissingAttributeViolationUsing` to report missing attribute violations to Sentry. + * + * @param callable|null $callback Optional callback to be called after the violation is reported to Sentry. + * @param bool $suppressDuplicateReports Whether to suppress duplicate reports of the same violation. + * @param bool $reportAfterResponse Whether to delay sending the report to after the response has been sent. + * + * @return callable + */ + public static function missingAttributeViolationReporter(?callable $callback = null, bool $suppressDuplicateReports = true, bool $reportAfterResponse = true): callable + { + return new ModelViolationReports\MissingAttributeModelViolationReporter($callback, $suppressDuplicateReports, $reportAfterResponse); + } + + /** + * Returns a callback that can be passed to `Model::handleLazyLoadingViolationUsing` to report lazy loading violations to Sentry. + * + * @param callable|null $callback Optional callback to be called after the violation is reported to Sentry. + * @param bool $suppressDuplicateReports Whether to suppress duplicate reports of the same violation. + * @param bool $reportAfterResponse Whether to delay sending the report to after the response has been sent. + * + * @return callable + */ + public static function lazyLoadingViolationReporter(?callable $callback = null, bool $suppressDuplicateReports = true, bool $reportAfterResponse = true): callable + { + return new ModelViolationReports\LazyLoadingModelViolationReporter($callback, $suppressDuplicateReports, $reportAfterResponse); + } + + /** + * Returns a callback that can be passed to `Model::handleDiscardedAttributeViolationUsing` to report discarded attribute violations to Sentry. + * + * @param callable|null $callback Optional callback to be called after the violation is reported to Sentry. + * @param bool $suppressDuplicateReports Whether to suppress duplicate reports of the same violation. + * @param bool $reportAfterResponse Whether to delay sending the report to after the response has been sent. + * + * @return callable + */ + public static function discardedAttributeViolationReporter(?callable $callback = null, bool $suppressDuplicateReports = true, bool $reportAfterResponse = true): callable + { + return new ModelViolationReports\DiscardedAttributeViolationReporter($callback, $suppressDuplicateReports, $reportAfterResponse); + } + + /** + * Try to make an educated guess if the call came from the Laravel `report` helper. + * + * @see https://github.com/laravel/framework/blob/008a4dd49c3a13343137d2bc43297e62006c7f29/src/Illuminate/Foundation/helpers.php#L667-L682 + * + * @return bool + */ + private static function makeAnEducatedGuessIfTheExceptionMaybeWasHandled(): bool + { + // We limit the amount of backtrace frames since it is very unlikely to be any deeper + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20); + + // We are looking for `$handler->report()` to be called from the `report()` function + foreach ($trace as $frameIndex => $frame) { + // We need a frame with a class and function defined, we can skip frames missing either + if (!isset($frame['class'], $frame['function'])) { + continue; + } + + // Check if the frame was indeed `$handler->report()` + if ($frame['type'] !== '->' || $frame['function'] !== 'report') { + continue; + } + + // Make sure we have a next frame, we could have reached the end of the trace + if (!isset($trace[$frameIndex + 1])) { + continue; + } + + // The next frame should contain the call to the `report()` helper function + $nextFrame = $trace[$frameIndex + 1]; + + // If a class was set or the function name is not `report` we can skip this frame + if (isset($nextFrame['class']) || !isset($nextFrame['function']) || $nextFrame['function'] !== 'report') { + continue; + } + + // If we reached this point we can be pretty sure the `report` function was called + // and we can come to the educated conclusion the exception was indeed handled + return true; + } + + // If we reached this point we can be pretty sure the `report` function was not called + return false; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Integration/ExceptionContextIntegration.php b/packages/laravel/src/Sentry/Laravel/Integration/ExceptionContextIntegration.php new file mode 100644 index 000000000000..74025312df1d --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Integration/ExceptionContextIntegration.php @@ -0,0 +1,39 @@ +getIntegration(self::class); + + if (!$self instanceof self) { + return $event; + } + + if ($hint === null || $hint->exception === null) { + return $event; + } + + if (!method_exists($hint->exception, 'context')) { + return $event; + } + + $context = $hint->exception->context(); + + if (is_array($context)) { + $event->setExtra(['exception_context' => $context]); + } + + return $event; + }); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Integration/LaravelContextIntegration.php b/packages/laravel/src/Sentry/Laravel/Integration/LaravelContextIntegration.php new file mode 100644 index 000000000000..59a77fd9b6a9 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Integration/LaravelContextIntegration.php @@ -0,0 +1,38 @@ +getIntegration(self::class); + + if (!$self instanceof self) { + return $event; + } + + if (!in_array($event->getType(), [EventType::event(), EventType::transaction()], true)) { + return $event; + } + + $event->setContext('laravel', app(ContextRepository::class)->all()); + + return $event; + }); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/DiscardedAttributeViolationReporter.php b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/DiscardedAttributeViolationReporter.php new file mode 100644 index 000000000000..6a8008ae3926 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/DiscardedAttributeViolationReporter.php @@ -0,0 +1,27 @@ + $property, + 'kind' => 'discarded_attribute', + ]; + } + + protected function getViolationException(Model $model, string $property): Exception + { + return new MassAssignmentException(sprintf( + 'Add [%s] to fillable property to allow mass assignment on [%s].', + $property, + get_class($model) + )); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/LazyLoadingModelViolationReporter.php b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/LazyLoadingModelViolationReporter.php new file mode 100644 index 000000000000..60ecf2dc7f65 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/LazyLoadingModelViolationReporter.php @@ -0,0 +1,34 @@ +exists || $model->wasRecentlyCreated) { + return false; + } + + return parent::shouldReport($model, $property); + } + + protected function getViolationContext(Model $model, string $property): array + { + return [ + 'relation' => $property, + 'kind' => 'lazy_loading', + ]; + } + + protected function getViolationException(Model $model, string $property): Exception + { + return new LazyLoadingViolationException($model, $property); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/MissingAttributeModelViolationReporter.php b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/MissingAttributeModelViolationReporter.php new file mode 100644 index 000000000000..8bdeb208715e --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/MissingAttributeModelViolationReporter.php @@ -0,0 +1,23 @@ + $property, + 'kind' => 'missing_attribute', + ]; + } + + protected function getViolationException(Model $model, string $property): Exception + { + return new MissingAttributeException($model, $property); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/ModelViolationReporter.php b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/ModelViolationReporter.php new file mode 100644 index 000000000000..952920eda3bb --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Integration/ModelViolations/ModelViolationReporter.php @@ -0,0 +1,108 @@ + $reportedViolations */ + private $reportedViolations = []; + + public function __construct(?callable $callback, bool $suppressDuplicateReports, bool $reportAfterResponse) + { + $this->callback = $callback; + $this->suppressDuplicateReports = $suppressDuplicateReports; + $this->reportAfterResponse = $reportAfterResponse; + } + + /** @param string|array $propertyOrProperties */ + public function __invoke(Model $model, $propertyOrProperties): void + { + $property = is_array($propertyOrProperties) + ? implode(', ', $propertyOrProperties) + : $propertyOrProperties; + + if (!$this->shouldReport($model, $property)) { + return; + } + + $this->markAsReported($model, $property); + + $origin = $this->resolveEventOrigin(); + + if ($this->reportAfterResponse) { + app()->terminating(function () use ($model, $property, $origin) { + $this->report($model, $property, $origin); + }); + } else { + $this->report($model, $property, $origin); + } + } + + abstract protected function getViolationContext(Model $model, string $property): array; + + abstract protected function getViolationException(Model $model, string $property): Exception; + + protected function shouldReport(Model $model, string $property): bool + { + if (!$this->suppressDuplicateReports) { + return true; + } + + return !array_key_exists(get_class($model) . $property, $this->reportedViolations); + } + + protected function markAsReported(Model $model, string $property): void + { + if (!$this->suppressDuplicateReports) { + return; + } + + $this->reportedViolations[get_class($model) . $property] = true; + } + + private function report(Model $model, string $property, $origin): void + { + SentrySdk::getCurrentHub()->withScope(function (Scope $scope) use ($model, $property, $origin) { + $scope->setContext('violation', array_merge([ + 'model' => get_class($model), + 'origin' => $origin, + ], $this->getViolationContext($model, $property))); + + SentrySdk::getCurrentHub()->captureEvent( + tap(Event::createEvent(), static function (Event $event) { + $event->setLevel(Severity::warning()); + }), + EventHint::fromArray([ + 'exception' => $this->getViolationException($model, $property), + 'mechanism' => new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, true), + ]) + ); + }); + + // Forward the violation to the next handler if there is one + if ($this->callback !== null) { + call_user_func($this->callback, $model, $property); + } + } +} diff --git a/packages/laravel/src/Sentry/Laravel/LogChannel.php b/packages/laravel/src/Sentry/Laravel/LogChannel.php new file mode 100644 index 000000000000..30d926f64f70 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/LogChannel.php @@ -0,0 +1,43 @@ +app->make(HubInterface::class), + $config['level'] ?? Logger::DEBUG, + $config['bubble'] ?? true, + $config['report_exceptions'] ?? true, + isset($config['formatter']) && $config['formatter'] !== 'default' + ); + + if (isset($config['action_level'])) { + $handler = new FingersCrossedHandler($handler, $config['action_level']); + + // Consume the `action_level` config option since newer Laravel versions also support this option + // and will wrap the handler again in another `FingersCrossedHandler` if we leave the option set + // See: https://github.com/laravel/framework/pull/40305 (release v8.79.0) + unset($config['action_level']); + } + + return new Logger( + $this->parseChannel($config), + [ + $this->prepareHandler($handler, $config), + ] + ); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/SentryHandler.php b/packages/laravel/src/Sentry/Laravel/SentryHandler.php new file mode 100644 index 000000000000..35d8bd2539b1 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/SentryHandler.php @@ -0,0 +1,355 @@ +hub = $hub; + $this->reportExceptions = $reportExceptions; + $this->useFormattedMessage = $useFormattedMessage; + } + + /** + * {@inheritdoc} + */ + public function handleBatch(array $records): void + { + $level = $this->level; + + // filter records based on their level + $records = array_filter( + $records, + function ($record) use ($level) { + return $record['level'] >= $level; + } + ); + + if (!$records) { + return; + } + + // the record with the highest severity is the "main" one + $record = array_reduce( + $records, + function ($highest, $record) { + if ($highest === null || $record['level'] > $highest['level']) { + return $record; + } + + return $highest; + } + ); + + // the other ones are added as a context item + $logs = []; + foreach ($records as $r) { + $logs[] = $this->processRecord($r); + } + + if ($logs) { + $record['context']['logs'] = (string)$this->getBatchFormatter()->formatBatch($logs); + } + + $this->handle($record); + } + + /** + * Sets the formatter for the logs generated by handleBatch(). + * + * @param FormatterInterface $formatter + * + * @return \Sentry\Laravel\SentryHandler + */ + public function setBatchFormatter(FormatterInterface $formatter): self + { + $this->batchFormatter = $formatter; + + return $this; + } + + /** + * Gets the formatter for the logs generated by handleBatch(). + */ + public function getBatchFormatter(): FormatterInterface + { + if (!$this->batchFormatter) { + $this->batchFormatter = $this->getDefaultBatchFormatter(); + } + + return $this->batchFormatter; + } + + /** + * Translates Monolog log levels to Sentry Severity. + * + * @param int $logLevel + * + * @return \Sentry\Severity + */ + protected function getLogLevel(int $logLevel): Severity + { + return $this->getSeverityFromLevel($logLevel); + } + + /** + * {@inheritdoc} + * @suppress PhanTypeMismatchArgument + */ + protected function doWrite($record): void + { + $exception = $record['context']['exception'] ?? null; + $isException = $exception instanceof Throwable; + unset($record['context']['exception']); + + if (!$this->reportExceptions && $isException) { + return; + } + + $this->hub->withScope( + function (Scope $scope) use ($record, $isException, $exception) { + $context = !empty($record['context']) && is_array($record['context']) + ? $record['context'] + : []; + + if (!empty($context)) { + $this->consumeContextAndApplyToScope($scope, $context); + } + + if (!empty($record['extra']) && is_array($record['extra'])) { + foreach ($record['extra'] as $key => $extra) { + $scope->setExtra($key, $extra); + } + } + + $logger = !empty($context['logger']) && is_string($context['logger']) + ? $context['logger'] + : null; + unset($context['logger']); + + // At this point we consumed everything we could from the context + // what remains we add as `log_context` to the event as a whole + if (!empty($context)) { + $scope->setExtra('log_context', $context); + } + + $scope->addEventProcessor( + function (Event $event) use ($record, $logger) { + $event->setLevel($this->getLogLevel($record['level'])); + $event->setLogger($logger ?? $record['channel']); + + if (!empty($this->environment) && !$event->getEnvironment()) { + $event->setEnvironment($this->environment); + } + + if (!empty($this->release) && !$event->getRelease()) { + $event->setRelease($this->release); + } + + if (isset($record['datetime']) && $record['datetime'] instanceof DateTimeInterface) { + $event->setTimestamp($record['datetime']->getTimestamp()); + } + + return $event; + } + ); + + if ($isException) { + $this->hub->captureException($exception); + } else { + $this->hub->captureMessage( + $this->useFormattedMessage || empty($record['message']) + ? $record['formatted'] + : $record['message'] + ); + } + } + ); + } + + /** + * {@inheritDoc} + */ + protected function getDefaultFormatter(): FormatterInterface + { + return new LineFormatter('[%channel%] %message%'); + } + + /** + * Gets the default formatter for the logs generated by handleBatch(). + * + * @return FormatterInterface + */ + protected function getDefaultBatchFormatter(): FormatterInterface + { + return new LineFormatter(); + } + + /** + * Set the release. + * + * @param string $value + * + * @return self + */ + public function setRelease($value): self + { + $this->release = $value; + + return $this; + } + + /** + * Set the current application environment. + * + * @param string $value + * + * @return self + */ + public function setEnvironment($value): self + { + $this->environment = $value; + + return $this; + } + + /** + * Add a breadcrumb. + * + * @link https://docs.sentry.io/learn/breadcrumbs/ + * + * @param \Sentry\Breadcrumb $crumb + * + * @return \Sentry\Laravel\SentryHandler + */ + public function addBreadcrumb(Breadcrumb $crumb): self + { + $this->hub->addBreadcrumb($crumb); + + return $this; + } + + /** + * Consumes the context and applies it to the scope. + * + * @param \Sentry\State\Scope $scope + * @param array $context + * + * @return void + */ + private function consumeContextAndApplyToScope(Scope $scope, array &$context): void + { + if (!empty($context['extra']) && is_array($context['extra'])) { + foreach ($context['extra'] as $key => $value) { + $scope->setExtra($key, $value); + } + + unset($context['extra']); + } + + if (!empty($context['tags']) && is_array($context['tags'])) { + foreach ($context['tags'] as $tag => $value) { + // Ignore tags with a value that is not a string or can be casted to a string + if (!$this->valueCanBeString($value)) { + continue; + } + + $scope->setTag($tag, (string)$value); + } + + unset($context['tags']); + } + + if (!empty($context['fingerprint']) && is_array($context['fingerprint'])) { + $scope->setFingerprint($context['fingerprint']); + + unset($context['fingerprint']); + } + + if (!empty($context['user']) && is_array($context['user'])) { + try { + $scope->setUser($context['user']); + + unset($context['user']); + } catch (TypeError $e) { + // In some cases the context can be invalid, in that case we ignore it and + // choose to not send it to Sentry in favor of not breaking the application + } + } + } + + /** + * Check if the value passed can be cast to a string. + * + * @param mixed $value + * + * @return bool + */ + private function valueCanBeString($value): bool + { + return is_string($value) || is_scalar($value) || (is_object($value) && method_exists($value, '__toString')); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/ServiceProvider.php b/packages/laravel/src/Sentry/Laravel/ServiceProvider.php new file mode 100644 index 000000000000..4177e2b68b75 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/ServiceProvider.php @@ -0,0 +1,450 @@ +app->make(HubInterface::class); + + $this->bootFeatures(); + + // Only register if a DSN is set or Spotlight is enabled + // No events can be sent without a DSN set or Spotlight enabled + if ($this->hasDsnSet() || $this->hasSpotlightEnabled()) { + $this->bindEvents(); + + if ($this->app instanceof Lumen) { + $this->app->middleware(SetRequestMiddleware::class); + $this->app->middleware(SetRequestIpMiddleware::class); + $this->app->middleware(FlushEventsMiddleware::class); + } elseif ($this->app->bound(HttpKernelInterface::class)) { + $httpKernel = $this->app->make(HttpKernelInterface::class); + + if ($httpKernel instanceof HttpKernel) { + $httpKernel->pushMiddleware(SetRequestMiddleware::class); + $httpKernel->pushMiddleware(SetRequestIpMiddleware::class); + $httpKernel->pushMiddleware(FlushEventsMiddleware::class); + } + } + } + + if ($this->app->runningInConsole()) { + if ($this->app instanceof Laravel) { + $this->publishes([ + __DIR__ . '/../../../config/sentry.php' => config_path(static::$abstract . '.php'), + ], 'config'); + } + + $this->registerArtisanCommands(); + } + + $this->registerAboutCommandIntegration(); + } + + /** + * Register the service provider. + */ + public function register(): void + { + if ($this->app instanceof Lumen) { + $this->app->configure(static::$abstract); + } + + $this->mergeConfigFrom(__DIR__ . '/../../../config/sentry.php', static::$abstract); + + $this->app->singleton(DebugFileLogger::class, function () { + return new DebugFileLogger(storage_path('logs/sentry.log')); + }); + + $this->configureAndRegisterClient(); + + $this->registerFeatures(); + } + + /** + * Bind to the Laravel event dispatcher to log events. + */ + protected function bindEvents(): void + { + $userConfig = $this->getUserConfig(); + + $handler = new EventHandler($this->app, $userConfig); + + try { + /** @var \Illuminate\Contracts\Events\Dispatcher $dispatcher */ + $dispatcher = $this->app->make(Dispatcher::class); + + $handler->subscribe($dispatcher); + + if ($this->app->bound('octane')) { + $handler->subscribeOctaneEvents($dispatcher); + } + + if (isset($userConfig['send_default_pii']) && $userConfig['send_default_pii'] !== false) { + $handler->subscribeAuthEvents($dispatcher); + } + } catch (BindingResolutionException $e) { + // If we cannot resolve the event dispatcher we also cannot listen to events + } + } + + /** + * Bind and register all the features. + */ + protected function registerFeatures(): void + { + // Register all the features as singletons, so there is only one instance of each feature in the application + foreach (self::FEATURES as $feature) { + $this->app->singleton($feature); + } + + foreach (self::FEATURES as $feature) { + try { + /** @var Feature $featureInstance */ + $featureInstance = $this->app->make($feature); + + $featureInstance->register(); + } catch (Throwable $e) { + // Ensure that features do not break the whole application + } + } + } + + /** + * Boot all the features. + */ + protected function bootFeatures(): void + { + // Only register if a DSN is set or Spotlight is enabled + // No events can be sent without a DSN set or Spotlight enabled + $bootActive = $this->hasDsnSet() || $this->hasSpotlightEnabled(); + + foreach (self::FEATURES as $feature) { + try { + /** @var Feature $featureInstance */ + $featureInstance = $this->app->make($feature); + + $bootActive + ? $featureInstance->boot() + : $featureInstance->bootInactive(); + } catch (Throwable $e) { + // Ensure that features do not break the whole application + } + } + } + + /** + * Register the artisan commands. + */ + protected function registerArtisanCommands(): void + { + $this->commands([ + TestCommand::class, + PublishCommand::class, + ]); + } + + /** + * Register the `php artisan about` command integration. + */ + protected function registerAboutCommandIntegration(): void + { + // The about command is only available in Laravel 9 and up so we need to check if it's available to us + if (!class_exists(AboutCommand::class)) { + return; + } + + AboutCommand::add('Sentry', AboutCommandIntegration::class); + } + + /** + * Configure and register the Sentry client with the container. + */ + protected function configureAndRegisterClient(): void + { + $this->app->bind(ClientBuilder::class, function () { + $basePath = base_path(); + $userConfig = $this->getUserConfig(); + + foreach (static::LARAVEL_SPECIFIC_OPTIONS as $laravelSpecificOptionName) { + unset($userConfig[$laravelSpecificOptionName]); + } + + $options = \array_merge( + [ + 'prefixes' => [$basePath], + 'in_app_exclude' => [ + "{$basePath}/vendor", + "{$basePath}/artisan", + ], + ], + $userConfig + ); + + // When we get no environment from the (user) configuration we default to the Laravel environment + if (empty($options['environment'])) { + $options['environment'] = $this->app->environment(); + } + + if ($this->app instanceof Lumen) { + $wrapBeforeSend = function (?callable $userBeforeSend) { + return function (Event $event, ?EventHint $eventHint) use ($userBeforeSend) { + $request = $this->app->make(Request::class); + + if ($request !== null) { + $route = $request->route(); + + if ($route !== null) { + [$routeName, $transactionSource] = Integration::extractNameAndSourceForLumenRoute($request->route(), $request->path()); + + $event->setTransaction($routeName); + + $transactionMetadata = $event->getSdkMetadata('transaction_metadata'); + + if ($transactionMetadata instanceof TransactionMetadata) { + $transactionMetadata->setSource($transactionSource); + } + } + } + + if ($userBeforeSend !== null) { + return $userBeforeSend($event, $eventHint); + } + + return $event; + }; + }; + + $options['before_send'] = $wrapBeforeSend($options['before_send'] ?? null); + $options['before_send_transaction'] = $wrapBeforeSend($options['before_send_transaction'] ?? null); + } + + foreach (self::OPTIONS_TO_RESOLVE_FROM_CONTAINER as $option) { + if (isset($options[$option]) && is_string($options[$option])) { + $options[$option] = $this->app->make($options[$option]); + } + } + + $clientBuilder = ClientBuilder::create($options); + + // Set the Laravel SDK identifier and version + $clientBuilder->setSdkIdentifier(Version::SDK_IDENTIFIER); + $clientBuilder->setSdkVersion(Version::SDK_VERSION); + + return $clientBuilder; + }); + + $this->app->singleton(HubInterface::class, function () { + /** @var \Sentry\ClientBuilder $clientBuilder */ + $clientBuilder = $this->app->make(ClientBuilder::class); + + $options = $clientBuilder->getOptions(); + + $userConfig = $this->getUserConfig(); + + /** @var array|callable $userConfig */ + $userIntegrationOption = $userConfig['integrations'] ?? []; + + $userIntegrations = $this->resolveIntegrationsFromUserConfig( + \is_array($userIntegrationOption) + ? $userIntegrationOption + : [], + $userConfig['tracing']['default_integrations'] ?? true + ); + + $options->setIntegrations(static function (array $integrations) use ($options, $userIntegrations, $userIntegrationOption): array { + if ($options->hasDefaultIntegrations()) { + // Remove the default error and fatal exception listeners to let Laravel handle those + // itself. These event are still bubbling up through the documented changes in the users + // `ExceptionHandler` of their application or through the log channel integration to Sentry + $integrations = array_filter($integrations, static function (SdkIntegration\IntegrationInterface $integration): bool { + if ($integration instanceof SdkIntegration\ErrorListenerIntegration) { + return false; + } + + if ($integration instanceof SdkIntegration\ExceptionListenerIntegration) { + return false; + } + + if ($integration instanceof SdkIntegration\FatalErrorListenerIntegration) { + return false; + } + + // We also remove the default request integration so it can be readded + // after with a Laravel specific request fetcher. This way we can resolve + // the request from Laravel instead of constructing it from the global state + if ($integration instanceof SdkIntegration\RequestIntegration) { + return false; + } + + return true; + }); + + $integrations[] = new SdkIntegration\RequestIntegration( + new LaravelRequestFetcher + ); + } + + $integrations = array_merge( + $integrations, + [ + new Integration, + new Integration\LaravelContextIntegration, + new Integration\ExceptionContextIntegration, + ], + $userIntegrations + ); + + if (\is_callable($userIntegrationOption)) { + return $userIntegrationOption($integrations); + } + + return $integrations; + }); + + $hub = new Hub($clientBuilder->getClient()); + + SentrySdk::setCurrentHub($hub); + + return $hub; + }); + + $this->app->alias(HubInterface::class, static::$abstract); + + $this->app->singleton(BacktraceHelper::class, function () { + $sentry = $this->app->make(HubInterface::class); + + $options = $sentry->getClient()->getOptions(); + + return new BacktraceHelper($options, new RepresentationSerializer($options)); + }); + } + + /** + * Resolve the integrations from the user configuration with the container. + */ + private function resolveIntegrationsFromUserConfig(array $userIntegrations, bool $enableDefaultTracingIntegrations): array + { + $integrationsToResolve = $userIntegrations; + + if ($enableDefaultTracingIntegrations) { + $integrationsToResolve = array_merge($integrationsToResolve, TracingServiceProvider::DEFAULT_INTEGRATIONS); + } + + $integrations = []; + + foreach ($integrationsToResolve as $userIntegration) { + if ($userIntegration instanceof SdkIntegration\IntegrationInterface) { + $integrations[] = $userIntegration; + } elseif (\is_string($userIntegration)) { + $resolvedIntegration = $this->app->make($userIntegration); + + if (!$resolvedIntegration instanceof SdkIntegration\IntegrationInterface) { + throw new RuntimeException( + sprintf( + 'Sentry integrations must be an instance of `%s` got `%s`.', + SdkIntegration\IntegrationInterface::class, + get_class($resolvedIntegration) + ) + ); + } + + $integrations[] = $resolvedIntegration; + } else { + throw new RuntimeException( + sprintf( + 'Sentry integrations must either be a valid container reference or an instance of `%s`.', + SdkIntegration\IntegrationInterface::class + ) + ); + } + } + + return $integrations; + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return [static::$abstract]; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/BacktraceHelper.php b/packages/laravel/src/Sentry/Laravel/Tracing/BacktraceHelper.php new file mode 100644 index 000000000000..888ff4a01dea --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/BacktraceHelper.php @@ -0,0 +1,113 @@ +options = $options; + $this->frameBuilder = new FrameBuilder($options, $representationSerializer); + } + + /** + * 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; + } + + /** + * Takes a frame and if it's a compiled view path returns the original view path. + * + * @param \Sentry\Frame $frame + * + * @return string|null + */ + public function getOriginalViewPathForFrameOfCompiledViewPath(Frame $frame): ?string + { + // Check if we are dealing with a frame for a cached view path + if (!Str::startsWith($frame->getFile(), '/storage/framework/views/')) { + return null; + } + + // If for some reason the file does not exists, skip resolving + if (!file_exists($frame->getAbsoluteFilePath())) { + return null; + } + + $viewFileContents = file_get_contents($frame->getAbsoluteFilePath()); + + preg_match('/PATH (?.*?) ENDPATH/', $viewFileContents, $matches); + + // No path comment found in the file, must be a very old Laravel version + if (empty($matches['originalPath'])) { + return null; + } + + return $this->stripPrefixFromFilePath($matches['originalPath']); + } + + /** + * Removes from the given file path the specified prefixes. + * + * @param string $filePath The path to the file + */ + private function stripPrefixFromFilePath(string $filePath): string + { + foreach ($this->options->getPrefixes() as $prefix) { + if (Str::startsWith($filePath, $prefix)) { + return mb_substr($filePath, mb_strlen($prefix)); + } + } + + return $filePath; + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/EventHandler.php b/packages/laravel/src/Sentry/Laravel/Tracing/EventHandler.php new file mode 100644 index 000000000000..8b7903a1318a --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/EventHandler.php @@ -0,0 +1,299 @@ + 'routeMatched', + DatabaseEvents\QueryExecuted::class => 'queryExecuted', + RoutingEvents\ResponsePrepared::class => 'responsePrepared', + RoutingEvents\PreparingResponse::class => 'responsePreparing', + DatabaseEvents\TransactionBeginning::class => 'transactionBeginning', + DatabaseEvents\TransactionCommitted::class => 'transactionCommitted', + DatabaseEvents\TransactionRolledBack::class => 'transactionRolledBack', + ]; + + /** + * Indicates if we should we add SQL queries as spans. + * + * @var bool + */ + private $traceSqlQueries; + + /** + * Indicates if we should add query bindings to query spans. + * + * @var bool + */ + private $traceSqlBindings; + + /** + * Indicates if we should we add SQL query origin data to query spans. + * + * @var bool + */ + private $traceSqlQueryOrigin; + + /** + * The threshold in milliseconds to consider a SQL query origin. + * + * @var int + */ + private $traceSqlQueryOriginTreshHoldMs; + + /** + * Indicates if we should trace queue job spans. + * + * @var bool + */ + private $traceQueueJobs; + + /** + * Indicates if we should trace queue jobs as separate transactions. + * + * @var bool + */ + private $traceQueueJobsAsTransactions; + + /** + * Hold the stack of parent spans that need to be put back on the scope. + * + * @var array + */ + private $parentSpanStack = []; + + /** + * Hold the stack of current spans that need to be finished still. + * + * @var array + */ + private $currentSpanStack = []; + + /** + * EventHandler constructor. + */ + public function __construct(array $config) + { + $this->traceSqlQueries = ($config['sql_queries'] ?? true) === true; + $this->traceSqlBindings = ($config['sql_bindings'] ?? true) === true; + $this->traceSqlQueryOrigin = ($config['sql_origin'] ?? true) === true; + $this->traceSqlQueryOriginTreshHoldMs = $config['sql_origin_threshold_ms'] ?? 100; + + $this->traceQueueJobs = ($config['queue_jobs'] ?? false) === true; + $this->traceQueueJobsAsTransactions = ($config['queue_job_transactions'] ?? false) === true; + } + + /** + * Attach all event handlers. + * + * @uses self::routeMatchedHandler() + * @uses self::queryExecutedHandler() + * @uses self::responsePreparedHandler() + * @uses self::responsePreparingHandler() + * @uses self::transactionBeginningHandler() + * @uses self::transactionCommittedHandler() + * @uses self::transactionRolledBackHandler() + */ + public function subscribe(Dispatcher $dispatcher): void + { + foreach (static::$eventHandlerMap as $eventName => $handler) { + $dispatcher->listen($eventName, [$this, $handler]); + } + } + + /** + * Pass through the event and capture any errors. + * + * @param string $method + * @param array $arguments + */ + public function __call(string $method, array $arguments) + { + $handlerMethod = "{$method}Handler"; + + if (!method_exists($this, $handlerMethod)) { + throw new RuntimeException("Missing tracing event handler: {$handlerMethod}"); + } + + try { + $this->{$handlerMethod}(...$arguments); + } catch (Exception $e) { + // Ignore to prevent bubbling up errors in the SDK + } + } + + protected function routeMatchedHandler(RoutingEvents\RouteMatched $match): void + { + $transaction = SentrySdk::getCurrentHub()->getTransaction(); + + if ($transaction === null) { + return; + } + + [$transactionName, $transactionSource] = Integration::extractNameAndSourceForRoute($match->route); + + $transaction->setName($transactionName); + $transaction->getMetadata()->setSource($transactionSource); + } + + protected function queryExecutedHandler(DatabaseEvents\QueryExecuted $query): void + { + if (!$this->traceSqlQueries) { + return; + } + + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($parentSpan === null || !$parentSpan->getSampled()) { + return; + } + + $context = SpanContext::make() + ->setOp('db.sql.query') + ->setData([ + 'db.name' => $query->connection->getDatabaseName(), + 'db.system' => $query->connection->getDriverName(), + 'server.address' => $query->connection->getConfig('host'), + 'server.port' => $query->connection->getConfig('port'), + ]) + ->setOrigin('auto.db') + ->setDescription($query->sql) + ->setStartTimestamp(microtime(true) - $query->time / 1000); + + $context->setEndTimestamp($context->getStartTimestamp() + $query->time / 1000); + + if ($this->traceSqlBindings) { + $context->setData(array_merge($context->getData(), [ + 'db.sql.bindings' => $query->bindings + ])); + } + + if ($this->traceSqlQueryOrigin && $query->time >= $this->traceSqlQueryOriginTreshHoldMs) { + $queryOrigin = $this->resolveEventOrigin(); + + if ($queryOrigin !== null) { + $context->setData(array_merge($context->getData(), $queryOrigin)); + } + } + + $parentSpan->startChild($context); + } + + protected function responsePreparedHandler(RoutingEvents\ResponsePrepared $event): void + { + $span = $this->popSpan(); + + if ($span !== null) { + $span->finish(); + } + } + + protected function responsePreparingHandler(RoutingEvents\PreparingResponse $event): void + { + // If the response is already a Response object there is no need to handle the event anymore + // since there isn't going to be any real work going on, the response is already as prepared + // as it can be. So we ignore the event to prevent loggin a very short empty duplicated span + if ($event->response instanceof Response) { + return; + } + + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($parentSpan === null || !$parentSpan->getSampled()) { + return; + } + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('http.route.response') + ->setOrigin('auto.http.server') + ) + ); + } + + protected function transactionBeginningHandler(DatabaseEvents\TransactionBeginning $event): void + { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($parentSpan === null || !$parentSpan->getSampled()) { + return; + } + + $this->pushSpan( + $parentSpan->startChild( + SpanContext::make() + ->setOp('db.transaction') + ->setOrigin('auto.db') + ) + ); + } + + protected function transactionCommittedHandler(DatabaseEvents\TransactionCommitted $event): void + { + $span = $this->popSpan(); + + if ($span !== null) { + $span->setStatus(SpanStatus::ok()); + $span->finish(); + } + } + + protected function transactionRolledBackHandler(DatabaseEvents\TransactionRolledBack $event): void + { + $span = $this->popSpan(); + + if ($span !== null) { + $span->setStatus(SpanStatus::internalError()); + $span->finish(); + } + } + + private function pushSpan(Span $span): void + { + $hub = SentrySdk::getCurrentHub(); + + $this->parentSpanStack[] = $hub->getSpan(); + + $hub->setSpan($span); + + $this->currentSpanStack[] = $span; + } + + private function popSpan(): ?Span + { + if (count($this->currentSpanStack) === 0) { + return null; + } + + $parent = array_pop($this->parentSpanStack); + + SentrySdk::getCurrentHub()->setSpan($parent); + + return array_pop($this->currentSpanStack); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php b/packages/laravel/src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php new file mode 100644 index 000000000000..7a06289263a1 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/Integrations/LighthouseIntegration.php @@ -0,0 +1,263 @@ + */ + private $operations; + + /** @var \Sentry\Tracing\Span|null */ + private $previousSpan; + + /** @var \Sentry\Tracing\Span|null */ + private $requestSpan; + + /** @var \Sentry\Tracing\Span|null */ + private $operationSpan; + + /** @var \Illuminate\Contracts\Events\Dispatcher */ + private $eventDispatcher; + + /** + * Indicates if, when building the transaction name, the operation name should be ignored. + * + * @var bool + */ + private $ignoreOperationName; + + public function __construct(EventDispatcher $eventDispatcher, bool $ignoreOperationName = false) + { + $this->eventDispatcher = $eventDispatcher; + $this->ignoreOperationName = $ignoreOperationName; + } + + public function setupOnce(): void + { + if (!$this->isApplicable()) { + return; + } + + $this->eventDispatcher->listen(StartRequest::class, [$this, 'handleStartRequest']); + $this->eventDispatcher->listen(StartExecution::class, [$this, 'handleStartExecution']); + $this->eventDispatcher->listen(EndExecution::class, [$this, 'handleEndExecution']); + $this->eventDispatcher->listen(EndRequest::class, [$this, 'handleEndRequest']); + + Scope::addGlobalEventProcessor(function (Event $event): Event { + $currentHub = SentrySdk::getCurrentHub(); + $integration = $currentHub->getIntegration(self::class); + $client = $currentHub->getClient(); + + // The client bound to the current hub, if any, could not have this + // integration enabled. If this is the case, bail out + if (null === $integration || null === $client) { + return $event; + } + + $this->processEvent($event, $client->getOptions()); + + return $event; + }); + } + + private function processEvent(Event $event, Options $options): void + { + // Detect if we are processing a GraphQL request, if not skip processing the event + if ($event->getTransaction() === null || !Str::startsWith($event->getTransaction(), 'lighthouse?')) { + return; + } + + $requestData = $event->getRequest(); + + // Make sure we have the request data and it contains the query + if (!isset($requestData['data']['query'])) { + return; + } + + // https://develop.sentry.dev/sdk/features/#graphql-client-integrations + $requestData['api_target'] = 'graphql'; + + $event->setRequest($requestData); + } + + public function handleStartRequest(StartRequest $startRequest): void + { + $this->previousSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to handle the event + if ($this->previousSpan === null || !$this->previousSpan->getSampled()) { + return; + } + + $context = SpanContext::make() + ->setOp('graphql.request') + ->setOrigin('auto.graphql.server'); + + $this->operations = []; + $this->requestSpan = $this->previousSpan->startChild($context); + $this->operationSpan = null; + + SentrySdk::getCurrentHub()->setSpan($this->requestSpan); + } + + public function handleStartExecution(StartExecution $startExecution): void + { + if ($this->requestSpan === null) { + return; + } + + if (!$startExecution->query instanceof DocumentNode) { + return; + } + + $operationDefinition = $this->extractOperationDefinitionNode($startExecution->query); + + if ($operationDefinition === null) { + return; + } + + $this->operations[] = [$startExecution->operationName ?? null, $operationDefinition]; + + $this->updateTransactionName(); + + $context = SpanContext::make() + ->setOp("graphql.{$operationDefinition->operation}") + ->setOrigin('auto.graphql.server'); + + $this->operationSpan = $this->requestSpan->startChild($context); + + SentrySdk::getCurrentHub()->setSpan($this->operationSpan); + } + + public function handleEndExecution(EndExecution $endExecution): void + { + if ($this->operationSpan === null) { + return; + } + + $this->operationSpan->finish(); + $this->operationSpan = null; + + SentrySdk::getCurrentHub()->setSpan($this->requestSpan); + } + + public function handleEndRequest(EndRequest $endRequest): void + { + if ($this->requestSpan === null) { + return; + } + + $this->requestSpan->finish(); + $this->requestSpan = null; + + SentrySdk::getCurrentHub()->setSpan($this->previousSpan); + $this->previousSpan = null; + + $this->operations = []; + } + + private function updateTransactionName(): void + { + $transaction = SentrySdk::getCurrentHub()->getTransaction(); + + if ($transaction === null) { + return; + } + + $groupedOperations = []; + + foreach ($this->operations as [$operationName, $operation]) { + if (!isset($groupedOperations[$operation->operation])) { + $groupedOperations[$operation->operation] = []; + } + + if ($operationName === null || $this->ignoreOperationName) { + $groupedOperations[$operation->operation] = array_merge( + $groupedOperations[$operation->operation], + $this->extractOperationNames($operation) + ); + } else { + $groupedOperations[$operation->operation][] = $operationName; + } + } + + if (empty($groupedOperations)) { + return; + } + + array_walk($groupedOperations, static function (&$operations, string $operationType) { + sort($operations, SORT_STRING); + + $operations = "{$operationType}{" . implode(',', $operations) . '}'; + }); + + ksort($groupedOperations, SORT_STRING); + + $transactionName = 'lighthouse?' . implode('&', $groupedOperations); + + $transaction->setName($transactionName); + $transaction->getMetadata()->setSource(TransactionSource::custom()); + + Integration::setTransaction($transactionName); + } + + /** + * @return array + */ + private function extractOperationNames(OperationDefinitionNode $operation): array + { + if (!$this->ignoreOperationName && $operation->name !== null) { + return [$operation->name->value]; + } + + $selectionSet = []; + + /** @var \GraphQL\Language\AST\FieldNode $selection */ + foreach ($operation->selectionSet->selections as $selection) { + // Not respecting aliases because they are only relevant for clients + // and the tracing we extract here is targeted at server developers. + $selectionSet[] = $selection->name->value; + } + + sort($selectionSet, SORT_STRING); + + return $selectionSet; + } + + private function extractOperationDefinitionNode(DocumentNode $query): ?OperationDefinitionNode + { + foreach ($query->definitions as $definition) { + if ($definition instanceof OperationDefinitionNode) { + return $definition; + } + } + + return null; + } + + private function isApplicable(): bool + { + if (!class_exists(StartRequest::class) || !class_exists(StartExecution::class)) { + return false; + } + + return property_exists(StartExecution::class, 'query'); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/Middleware.php b/packages/laravel/src/Sentry/Laravel/Tracing/Middleware.php new file mode 100644 index 000000000000..d10ca91def44 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/Middleware.php @@ -0,0 +1,284 @@ +continueAfterResponse = $continueAfterResponse; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * + * @return mixed + */ + public function handle(Request $request, Closure $next) + { + if (app()->bound(HubInterface::class)) { + $this->startTransaction($request, app(HubInterface::class)); + } + + return $next($request); + } + + /** + * Handle the application termination. + * + * @param \Illuminate\Http\Request $request + * @param mixed $response + * + * @return void + */ + public function terminate(Request $request, $response): void + { + // If there is no transaction or the HubInterface is not bound in the container there is nothing for us to do + if ($this->transaction === null || !app()->bound(HubInterface::class)) { + return; + } + + if ($this->shouldRouteBeIgnored($request)) { + return; + } + + if ($this->appSpan !== null) { + $this->appSpan->finish(); + $this->appSpan = null; + } + + if ($response instanceof SymfonyResponse) { + $this->hydrateResponseData($response); + } + + if ($this->continueAfterResponse) { + // Resolving the transaction finisher class will register the terminating callback + // which is responsible for calling `finishTransaction`. We have registered the + // class as a singleton to keep the state in the container and away from here + // this way we ensure the callback is only registered once even for Octane. + app(TransactionFinisher::class); + } else { + $this->finishTransaction(); + } + } + + /** + * Set the timestamp of application bootstrap completion. + * + * @param float|null $timestamp The unix timestamp of the booted event, default to `microtime(true)` if not `null`. + * + * @return void + * + * @internal This method should only be invoked right after the application has finished "booting". + */ + public function setBootedTimestamp(?float $timestamp = null): void + { + $this->bootedTimestamp = $timestamp ?? microtime(true); + } + + private function startTransaction(Request $request, HubInterface $sentry): void + { + // Prevent starting a new transaction if we are already in a transaction + if ($sentry->getTransaction() !== null) { + return; + } + + // Reset our internal state in case we are handling multiple requests (e.g. in Octane) + $this->didRouteMatch = false; + + // Try $_SERVER['REQUEST_TIME_FLOAT'] then LARAVEL_START and fallback to microtime(true) if neither are defined + $requestStartTime = $request->server( + 'REQUEST_TIME_FLOAT', + defined('LARAVEL_START') + ? LARAVEL_START + : microtime(true) + ); + + $context = continueTrace( + $request->header('sentry-trace') ?? $request->header('traceparent', ''), + $request->header('baggage', '') + ); + + $requestPath = '/' . ltrim($request->path(), '/'); + + $context->setOp('http.server'); + $context->setName($requestPath); + $context->setOrigin('auto.http.server'); + $context->setSource(TransactionSource::url()); + $context->setStartTimestamp($requestStartTime); + + $context->setData([ + 'url' => $requestPath, + 'http.request.method' => strtoupper($request->method()), + ]); + + $transaction = $sentry->startTransaction($context); + + SentrySdk::getCurrentHub()->setSpan($transaction); + + // If this transaction is not sampled, we can stop here to prevent doing work for nothing + if (!$transaction->getSampled()) { + return; + } + + $this->transaction = $transaction; + + $bootstrapSpan = $this->addAppBootstrapSpan(); + + $this->appSpan = $this->transaction->startChild( + SpanContext::make() + ->setOp('middleware.handle') + ->setOrigin('auto.http.server') + ->setStartTimestamp($bootstrapSpan ? $bootstrapSpan->getEndTimestamp() : microtime(true)) + ); + + SentrySdk::getCurrentHub()->setSpan($this->appSpan); + } + + private function addAppBootstrapSpan(): ?Span + { + if ($this->bootedTimestamp === null) { + return null; + } + + $span = $this->transaction->startChild( + SpanContext::make() + ->setOp('app.bootstrap') + ->setOrigin('auto.http.server') + ->setStartTimestamp($this->transaction->getStartTimestamp()) + ->setEndTimestamp($this->bootedTimestamp) + ); + + // Add more information about the bootstrap section if possible + $this->addBootDetailTimeSpans($span); + + // Consume the booted timestamp, because we don't want to report the boot(strap) spans more than once + $this->bootedTimestamp = null; + + return $span; + } + + private function addBootDetailTimeSpans(Span $bootstrap): void + { + // This constant should be defined right after the composer `autoload.php` require statement in `public/index.php` + // define('SENTRY_AUTOLOAD', microtime(true)); + if (!defined('SENTRY_AUTOLOAD') || !SENTRY_AUTOLOAD) { + return; + } + + $bootstrap->startChild( + SpanContext::make() + ->setOp('app.php.autoload') + ->setOrigin('auto.http.server') + ->setStartTimestamp($this->transaction->getStartTimestamp()) + ->setEndTimestamp(SENTRY_AUTOLOAD) + ); + } + + private function hydrateResponseData(SymfonyResponse $response): void + { + $this->transaction->setHttpStatus($response->getStatusCode()); + } + + public function finishTransaction(): void + { + // We could end up multiple times here since we register a terminating callback so + // double check if we have a transaction before trying to finish it since it could + // have already been finished in between being registered and being executed again + if ($this->transaction === null) { + return; + } + + // Make sure we set the transaction and not have a child span in the Sentry SDK + // If the transaction is not on the scope during finish, the trace.context is wrong + SentrySdk::getCurrentHub()->setSpan($this->transaction); + + $this->transaction->finish(); + $this->transaction = null; + } + + private function internalSignalRouteWasMatched(): void + { + $this->didRouteMatch = true; + } + + /** + * Indicates if the route should be ignored and the transaction discarded. + */ + private function shouldRouteBeIgnored(Request $request): bool + { + // Laravel Lumen doesn't use `illuminate/routing`. + // Instead we use the route available on the request to detect if a route was matched. + if (app() instanceof LumenApplication) { + return $request->route() === null && config('sentry.tracing.missing_routes', false) === false; + } + + // If a route has not been matched we ignore unless we are configured to trace missing routes + return !$this->didRouteMatch && config('sentry.tracing.missing_routes', false) === false; + } + + public static function signalRouteWasMatched(): void + { + if (!app()->bound(self::class)) { + return; + } + + app(self::class)->internalSignalRouteWasMatched(); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingCallableDispatcherTracing.php b/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingCallableDispatcherTracing.php new file mode 100644 index 000000000000..7ccb10624af9 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingCallableDispatcherTracing.php @@ -0,0 +1,24 @@ +dispatcher = $dispatcher; + } + + public function dispatch(Route $route, $callable) + { + return $this->wrapRouteDispatch(function () use ($route, $callable) { + return $this->dispatcher->dispatch($route, $callable); + }, $route); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingControllerDispatcherTracing.php b/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingControllerDispatcherTracing.php new file mode 100644 index 000000000000..1cedda2c68cf --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingControllerDispatcherTracing.php @@ -0,0 +1,29 @@ +dispatcher = $dispatcher; + } + + public function dispatch(Route $route, $controller, $method) + { + return $this->wrapRouteDispatch(function () use ($route, $controller, $method) { + return $this->dispatcher->dispatch($route, $controller, $method); + }, $route); + } + + public function getMiddleware($controller, $method) + { + return $this->dispatcher->getMiddleware($controller, $method); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingRoutingDispatcher.php b/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingRoutingDispatcher.php new file mode 100644 index 000000000000..ff641c56b194 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/Routing/TracingRoutingDispatcher.php @@ -0,0 +1,42 @@ +getSpan(); + + // If there is no sampled span there is no need to wrap the dispatch + if ($parentSpan === null || !$parentSpan->getSampled()) { + return $dispatch(); + } + + // The action name can be a Closure curiously enough... so we guard againt that here + // @see: https://github.com/getsentry/sentry-laravel/issues/917 + $action = $route->getActionName() instanceof Closure ? 'Closure' : $route->getActionName(); + + $span = $parentSpan->startChild( + SpanContext::make() + ->setOp('http.route') + ->setOrigin('auto.http.server') + ->setDescription($action) + ); + + SentrySdk::getCurrentHub()->setSpan($span); + + try { + return $dispatch(); + } finally { + $span->finish(); + + SentrySdk::getCurrentHub()->setSpan($parentSpan); + } + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/ServiceProvider.php b/packages/laravel/src/Sentry/Laravel/Tracing/ServiceProvider.php new file mode 100644 index 000000000000..f045d5f49d12 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/ServiceProvider.php @@ -0,0 +1,147 @@ +hasDsnSet() && !$this->hasSpotlightEnabled()) { + return; + } + + if (!$this->app instanceof Lumen) { + $this->app->booted(function () { + $this->app->make(Middleware::class)->setBootedTimestamp(); + }); + } + + $tracingConfig = $this->getTracingConfig(); + + $this->bindEvents($tracingConfig); + + $this->bindViewEngine($tracingConfig); + + $this->decorateRoutingDispatchers(); + + if ($this->app instanceof Lumen) { + $this->app->middleware(Middleware::class); + } elseif ($this->app->bound(HttpKernelInterface::class)) { + $httpKernel = $this->app->make(HttpKernelInterface::class); + + if ($httpKernel instanceof HttpKernel) { + $httpKernel->prependMiddleware(Middleware::class); + } + } + } + + public function register(): void + { + $this->app->singleton(TransactionFinisher::class); + + $this->app->singleton(Middleware::class, function () { + $continueAfterResponse = ($this->getTracingConfig()['continue_after_response'] ?? true) === true; + + // Lumen introduced the `terminating` method in version 9.1.4. + // We check for it's existence and disable the continue after response feature if it's not available. + if (!method_exists($this->app, 'terminating')) { + $continueAfterResponse = false; + } + + return new Middleware($continueAfterResponse); + }); + } + + private function bindEvents(array $tracingConfig): void + { + $handler = new EventHandler($tracingConfig); + + try { + /** @var \Illuminate\Contracts\Events\Dispatcher $dispatcher */ + $dispatcher = $this->app->make(Dispatcher::class); + + $handler->subscribe($dispatcher); + } catch (BindingResolutionException $e) { + // If we cannot resolve the event dispatcher we also cannot listen to events + } + } + + private function bindViewEngine($tracingConfig): void + { + if (($tracingConfig['views'] ?? true) !== true) { + return; + } + + $viewEngineWrapper = function (EngineResolver $engineResolver): void { + foreach (['file', 'php', 'blade'] as $engineName) { + try { + $realEngine = $engineResolver->resolve($engineName); + + $engineResolver->register($engineName, function () use ($realEngine) { + return $this->wrapViewEngine($realEngine); + }); + } catch (InvalidArgumentException $e) { + // The `file` engine was introduced in Laravel 5.4. On lower Laravel versions + // resolving that driver will throw an `InvalidArgumentException`. We can + // ignore this exception because we can't wrap drivers that don't exist + } + } + }; + + if ($this->app->resolved('view.engine.resolver')) { + $viewEngineWrapper($this->app->make('view.engine.resolver')); + } else { + $this->app->afterResolving('view.engine.resolver', $viewEngineWrapper); + } + } + + private function wrapViewEngine(Engine $realEngine): Engine + { + /** @var ViewFactory $viewFactory */ + $viewFactory = $this->app->make('view'); + + $viewFactory->composer('*', static function (View $view) use ($viewFactory): void { + $viewFactory->share(ViewEngineDecorator::SHARED_KEY, $view->name()); + }); + + return new ViewEngineDecorator($realEngine, $viewFactory); + } + + private function getTracingConfig(): array + { + return $this->getUserConfig()['tracing'] ?? []; + } + + private function decorateRoutingDispatchers(): void + { + $this->app->extend(CallableDispatcher::class, static function (CallableDispatcher $dispatcher) { + return new TracingCallableDispatcherTracing($dispatcher); + }); + + $this->app->extend(ControllerDispatcher::class, static function (ControllerDispatcher $dispatcher) { + return new TracingControllerDispatcherTracing($dispatcher); + }); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/TransactionFinisher.php b/packages/laravel/src/Sentry/Laravel/Tracing/TransactionFinisher.php new file mode 100644 index 000000000000..d75a26eee7f9 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/TransactionFinisher.php @@ -0,0 +1,28 @@ +afterResponse() which are terminating + // callbacks themselfs just like we do below. + // + // This class is registered as a singleton in the container to ensure it's only + // instantiated once and the terminating callback is only registered once. + // + // It should be resolved from the container before the terminating callbacks are called. + // Good place is in the `terminate` callback of a middleware for example. + // This way we can be 99.9% sure to be the last ones to run. + app()->terminating(function () { + app(Middleware::class)->finishTransaction(); + }); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Tracing/ViewEngineDecorator.php b/packages/laravel/src/Sentry/Laravel/Tracing/ViewEngineDecorator.php new file mode 100644 index 000000000000..95ca87ed0e37 --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Tracing/ViewEngineDecorator.php @@ -0,0 +1,60 @@ +engine = $engine; + $this->viewFactory = $viewFactory; + } + + /** + * {@inheritdoc} + */ + public function get($path, array $data = []): string + { + $parentSpan = SentrySdk::getCurrentHub()->getSpan(); + + // If there is no sampled span there is no need to wrap the engine call + if ($parentSpan === null || !$parentSpan->getSampled()) { + return $this->engine->get($path, $data); + } + + $span = $parentSpan->startChild( + SpanContext::make() + ->setOp('view.render') + ->setOrigin('auto.view') + ->setDescription($this->viewFactory->shared(self::SHARED_KEY, basename($path))) + ); + + SentrySdk::getCurrentHub()->setSpan($span); + + $result = $this->engine->get($path, $data); + + $span->finish(); + + SentrySdk::getCurrentHub()->setSpan($parentSpan); + + return $result; + } + + public function __call($name, $arguments) + { + return $this->engine->{$name}(...$arguments); + } +} diff --git a/packages/laravel/src/Sentry/Laravel/Util/Filesize.php b/packages/laravel/src/Sentry/Laravel/Util/Filesize.php new file mode 100644 index 000000000000..d347ee6c5e3d --- /dev/null +++ b/packages/laravel/src/Sentry/Laravel/Util/Filesize.php @@ -0,0 +1,31 @@ +extend(ClientBuilder::class, function (ClientBuilder $clientBuilder) { + $clientBuilder->getOptions()->setEnvironment('from_service_container'); + + return $clientBuilder; + }); + } + + public function testClientHasEnvironmentSetFromDecorator(): void + { + $this->assertEquals( + 'from_service_container', + $this->getSentryClientFromContainer()->getOptions()->getEnvironment() + ); + } +} diff --git a/packages/laravel/test/Sentry/Console/AboutCommandIntegrationTest.php b/packages/laravel/test/Sentry/Console/AboutCommandIntegrationTest.php new file mode 100644 index 000000000000..64419a56039f --- /dev/null +++ b/packages/laravel/test/Sentry/Console/AboutCommandIntegrationTest.php @@ -0,0 +1,88 @@ +markTestSkipped('The about command is only available in Laravel 9.0+'); + } + + parent::setUp(); + } + + public function testAboutCommandContainsExpectedData(): void + { + $this->resetApplicationWithConfig([ + 'sentry.release' => '1.2.3', + 'sentry.environment' => 'testing', + 'sentry.traces_sample_rate' => 0.95, + ]); + + $expectedData = [ + 'environment' => 'testing', + 'release' => '1.2.3', + 'sample_rate_errors' => '100%', + 'sample_rate_profiling' => 'NOT SET', + 'sample_rate_performance_monitoring' => '95%', + 'send_default_pii' => 'DISABLED', + 'php_sdk_version' => Client::SDK_VERSION, + 'laravel_sdk_version' => Version::SDK_VERSION, + ]; + + $actualData = $this->runArtisanAboutAndReturnSentryData(); + + foreach ($expectedData as $key => $value) { + $this->assertArrayHasKey($key, $actualData); + $this->assertEquals($value, $actualData[$key]); + } + } + + public function testAboutCommandContainsExpectedDataWithoutHubClient(): void + { + $this->app->bind(HubInterface::class, static function () { + return new Hub(null); + }); + + $expectedData = [ + 'enabled' => 'NOT CONFIGURED', + 'php_sdk_version' => Client::SDK_VERSION, + 'laravel_sdk_version' => Version::SDK_VERSION, + ]; + + $actualData = $this->runArtisanAboutAndReturnSentryData(); + + foreach ($expectedData as $key => $value) { + $this->assertArrayHasKey($key, $actualData); + $this->assertEquals($value, $actualData[$key]); + } + } + + private function runArtisanAboutAndReturnSentryData(): array + { + $this->withoutMockingConsoleOutput(); + + $this->artisan(AboutCommand::class, ['--json' => null]); + + $output = Artisan::output(); + + // This might seem like a weird thing to do, but it's necessary to make sure that that the command didn't have any side effects on the container + $this->refreshApplication(); + + $aboutOutput = json_decode($output, true); + + $this->assertArrayHasKey('sentry', $aboutOutput); + + return $aboutOutput['sentry']; + } +} diff --git a/packages/laravel/test/Sentry/EventHandler/AuthEventsTest.php b/packages/laravel/test/Sentry/EventHandler/AuthEventsTest.php new file mode 100644 index 000000000000..1f5769d2fd42 --- /dev/null +++ b/packages/laravel/test/Sentry/EventHandler/AuthEventsTest.php @@ -0,0 +1,79 @@ + true, + ]; + + public function testAuthenticatedEventFillsUserOnScope(): void + { + $user = new AuthEventsTestUserModel(); + + $user->id = 123; + $user->username = 'username'; + $user->email = 'foo@example.com'; + + $scope = $this->getCurrentSentryScope(); + + $this->assertNull($scope->getUser()); + + $this->dispatchLaravelEvent(new Authenticated('test', $user)); + + $this->assertNotNull($scope->getUser()); + + $this->assertEquals($scope->getUser()->getId(), 123); + $this->assertEquals($scope->getUser()->getUsername(), 'username'); + $this->assertEquals($scope->getUser()->getEmail(), 'foo@example.com'); + } + + public function testAuthenticatedEventFillsUserOnScopeWhenUsernameIsNotAString(): void + { + $user = new AuthEventsTestUserModel(); + + $user->id = 123; + $user->username = 456; + + $scope = $this->getCurrentSentryScope(); + + $this->assertNull($scope->getUser()); + + $this->dispatchLaravelEvent(new Authenticated('test', $user)); + + $this->assertNotNull($scope->getUser()); + + $this->assertEquals($scope->getUser()->getId(), 123); + $this->assertEquals($scope->getUser()->getUsername(), '456'); + } + + public function testAuthenticatedEventDoesNotFillUserOnScopeWhenPIIShouldNotBeSent(): void + { + $this->resetApplicationWithConfig([ + 'sentry.send_default_pii' => false, + ]); + + $user = new AuthEventsTestUserModel(); + + $user->id = 123; + + $scope = $this->getCurrentSentryScope(); + + $this->assertNull($scope->getUser()); + + $this->dispatchLaravelEvent(new Authenticated('test', $user)); + + $this->assertNull($scope->getUser()); + } +} + +class AuthEventsTestUserModel extends Model implements Authenticatable +{ + use \Illuminate\Auth\Authenticatable; +} diff --git a/packages/laravel/test/Sentry/EventHandler/DatabaseEventsTest.php b/packages/laravel/test/Sentry/EventHandler/DatabaseEventsTest.php new file mode 100644 index 000000000000..531306e8341e --- /dev/null +++ b/packages/laravel/test/Sentry/EventHandler/DatabaseEventsTest.php @@ -0,0 +1,97 @@ +resetApplicationWithConfig([ + 'sentry.breadcrumbs.sql_queries' => true, + ]); + + $this->assertTrue($this->app['config']->get('sentry.breadcrumbs.sql_queries')); + + $this->dispatchLaravelEvent(new QueryExecuted( + $query = 'SELECT * FROM breadcrumbs WHERE bindings = ?;', + ['1'], + 10, + $this->getMockedConnection() + )); + + $lastBreadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals($query, $lastBreadcrumb->getMessage()); + } + + public function testSqlBindingsAreRecordedWhenEnabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.sql_bindings' => true, + ]); + + $this->assertTrue($this->app['config']->get('sentry.breadcrumbs.sql_bindings')); + + $this->dispatchLaravelEvent(new QueryExecuted( + $query = 'SELECT * FROM breadcrumbs WHERE bindings = ?;', + $bindings = ['1'], + 10, + $this->getMockedConnection() + )); + + $lastBreadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals($query, $lastBreadcrumb->getMessage()); + $this->assertEquals($bindings, $lastBreadcrumb->getMetadata()['bindings']); + } + + public function testSqlQueriesAreRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.sql_queries' => false, + ]); + + $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.sql_queries')); + + $this->dispatchLaravelEvent(new QueryExecuted( + 'SELECT * FROM breadcrumbs WHERE bindings = ?;', + ['1'], + 10, + $this->getMockedConnection() + )); + + $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); + } + + public function testSqlBindingsAreRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.sql_bindings' => false, + ]); + + $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.sql_bindings')); + + $this->dispatchLaravelEvent(new QueryExecuted( + $query = 'SELECT * FROM breadcrumbs WHERE bindings <> ?;', + ['1'], + 10, + $this->getMockedConnection() + )); + + $lastBreadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals($query, $lastBreadcrumb->getMessage()); + $this->assertFalse(isset($lastBreadcrumb->getMetadata()['bindings'])); + } + + private function getMockedConnection() + { + return Mockery::mock(Connection::class) + ->shouldReceive('getName')->andReturn('test'); + } +} diff --git a/packages/laravel/test/Sentry/EventHandler/LogEventsTest.php b/packages/laravel/test/Sentry/EventHandler/LogEventsTest.php new file mode 100644 index 000000000000..2847477e626a --- /dev/null +++ b/packages/laravel/test/Sentry/EventHandler/LogEventsTest.php @@ -0,0 +1,43 @@ +resetApplicationWithConfig([ + 'sentry.breadcrumbs.logs' => true, + ]); + + $this->assertTrue($this->app['config']->get('sentry.breadcrumbs.logs')); + + $this->dispatchLaravelEvent(new MessageLogged( + $level = 'debug', + $message = 'test message', + $context = ['1'] + )); + + $lastBreadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals($level, $lastBreadcrumb->getLevel()); + $this->assertEquals($message, $lastBreadcrumb->getMessage()); + $this->assertEquals($context, $lastBreadcrumb->getMetadata()); + } + + public function testLaravelLogsAreRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.logs' => false, + ]); + + $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.logs')); + + $this->dispatchLaravelEvent(new MessageLogged('debug', 'test message')); + + $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); + } +} diff --git a/packages/laravel/test/Sentry/EventHandlerTest.php b/packages/laravel/test/Sentry/EventHandlerTest.php new file mode 100644 index 000000000000..9b87370cda22 --- /dev/null +++ b/packages/laravel/test/Sentry/EventHandlerTest.php @@ -0,0 +1,64 @@ +app, []); + + $this->expectException(RuntimeException::class); + + /** @noinspection PhpUndefinedMethodInspection */ + $handler->thisIsNotAHandlerAndShouldThrowAnException(); + } + + public function testAllMappedEventHandlersExist(): void + { + $this->tryAllEventHandlerMethods( + $this->getEventHandlerMapFromEventHandler('eventHandlerMap') + ); + } + + public function testAllMappedAuthEventHandlersExist(): void + { + $this->tryAllEventHandlerMethods( + $this->getEventHandlerMapFromEventHandler('authEventHandlerMap') + ); + } + + public function testAllMappedOctaneEventHandlersExist(): void + { + $this->tryAllEventHandlerMethods( + $this->getEventHandlerMapFromEventHandler('octaneEventHandlerMap') + ); + } + + private function tryAllEventHandlerMethods(array $methods): void + { + $handler = new EventHandler($this->app, []); + + $methods = array_map(static function ($method) { + return "{$method}Handler"; + }, array_unique(array_values($methods))); + + foreach ($methods as $handlerMethod) { + $this->assertTrue(method_exists($handler, $handlerMethod)); + } + } + + private function getEventHandlerMapFromEventHandler($eventHandlerMapName) + { + $class = new ReflectionClass(EventHandler::class); + + $attributes = $class->getStaticProperties(); + + return $attributes[$eventHandlerMapName]; + } +} diff --git a/packages/laravel/test/Sentry/Features/CacheIntegrationTest.php b/packages/laravel/test/Sentry/Features/CacheIntegrationTest.php new file mode 100644 index 000000000000..52ed81f329e6 --- /dev/null +++ b/packages/laravel/test/Sentry/Features/CacheIntegrationTest.php @@ -0,0 +1,171 @@ +assertEquals("Written: {$key}", $this->getLastSentryBreadcrumb()->getMessage()); + + Cache::get('foo'); + + $this->assertEquals("Read: {$key}", $this->getLastSentryBreadcrumb()->getMessage()); + } + + public function testCacheBreadcrumbForWriteAndForgetIsRecorded(): void + { + Cache::put($key = 'foo', 'bar'); + + $this->assertEquals("Written: {$key}", $this->getLastSentryBreadcrumb()->getMessage()); + + Cache::forget($key); + + $this->assertEquals("Forgotten: {$key}", $this->getLastSentryBreadcrumb()->getMessage()); + } + + public function testCacheBreadcrumbForMissIsRecorded(): void + { + Cache::get($key = 'foo'); + + $this->assertEquals("Missed: {$key}", $this->getLastSentryBreadcrumb()->getMessage()); + } + + public function testCacheBreadcrumbIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.cache' => false, + ]); + + $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.cache')); + + Cache::get('foo'); + + $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); + } + + public function testCacheGetSpanIsRecorded(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::get('foo'); + }); + + $this->assertEquals('cache.get', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + $this->assertFalse($span->getData()['cache.hit']); + } + + public function testCacheGetSpanIsRecordedForBatchOperation(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::get(['foo', 'bar']); + }); + + $this->assertEquals('cache.get', $span->getOp()); + $this->assertEquals('foo, bar', $span->getDescription()); + $this->assertEquals(['foo', 'bar'], $span->getData()['cache.key']); + } + + public function testCacheGetSpanIsRecordedForMultipleOperation(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::getMultiple(['foo', 'bar']); + }); + + $this->assertEquals('cache.get', $span->getOp()); + $this->assertEquals('foo, bar', $span->getDescription()); + $this->assertEquals(['foo', 'bar'], $span->getData()['cache.key']); + } + + public function testCacheGetSpanIsRecordedWithCorrectHitData(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::put('foo', 'bar'); + Cache::get('foo'); + }); + + $this->assertEquals('cache.get', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + $this->assertTrue($span->getData()['cache.hit']); + } + + public function testCachePutSpanIsRecorded(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::put('foo', 'bar', 99); + }); + + $this->assertEquals('cache.put', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + $this->assertEquals(99, $span->getData()['cache.ttl']); + } + + public function testCachePutSpanIsRecordedForManyOperation(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::putMany(['foo' => 'bar', 'baz' => 'qux'], 99); + }); + + $this->assertEquals('cache.put', $span->getOp()); + $this->assertEquals('foo, baz', $span->getDescription()); + $this->assertEquals(['foo', 'baz'], $span->getData()['cache.key']); + $this->assertEquals(99, $span->getData()['cache.ttl']); + } + + public function testCacheRemoveSpanIsRecorded(): void + { + $this->markSkippedIfTracingEventsNotAvailable(); + + $span = $this->executeAndReturnMostRecentSpan(function () { + Cache::forget('foo'); + }); + + $this->assertEquals('cache.remove', $span->getOp()); + $this->assertEquals('foo', $span->getDescription()); + $this->assertEquals(['foo'], $span->getData()['cache.key']); + } + + private function markSkippedIfTracingEventsNotAvailable(): void + { + if (class_exists(RetrievingKey::class)) { + return; + } + + $this->markTestSkipped('The required cache events are not available in this Laravel version'); + } + + private function executeAndReturnMostRecentSpan(callable $callable): Span + { + $transaction = $this->startTransaction(); + + $callable(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertTrue(count($spans) >= 2); + + return array_pop($spans); + } +} diff --git a/packages/laravel/test/Sentry/Features/ConsoleIntegrationTest.php b/packages/laravel/test/Sentry/Features/ConsoleIntegrationTest.php new file mode 100644 index 000000000000..1f26547966a2 --- /dev/null +++ b/packages/laravel/test/Sentry/Features/ConsoleIntegrationTest.php @@ -0,0 +1,51 @@ +resetApplicationWithConfig([ + 'sentry.breadcrumbs.command_info' => true, + ]); + + $this->assertTrue($this->app['config']->get('sentry.breadcrumbs.command_info')); + + $this->dispatchCommandStartEvent(); + + $lastBreadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals('Starting Artisan command: test:command', $lastBreadcrumb->getMessage()); + $this->assertEquals('--foo=bar', $lastBreadcrumb->getMetadata()['input']); + } + + public function testCommandBreadcrumIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.command_info' => false, + ]); + + $this->assertFalse($this->app['config']->get('sentry.breadcrumbs.command_info')); + + $this->dispatchCommandStartEvent(); + + $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); + } + + private function dispatchCommandStartEvent(): void + { + $this->dispatchLaravelEvent( + new CommandStarting( + 'test:command', + new ArgvInput(['artisan', '--foo=bar']), + new BufferedOutput() + ) + ); + } +} diff --git a/packages/laravel/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php b/packages/laravel/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php new file mode 100644 index 000000000000..f1c7bc15fa7a --- /dev/null +++ b/packages/laravel/test/Sentry/Features/ConsoleSchedulingIntegrationTest.php @@ -0,0 +1,128 @@ +getScheduler() + ->call(function () {}) + ->sentryMonitor('test-monitor'); + + $scheduledEvent->run($this->app); + + // We expect a total of 2 events to be sent to Sentry: + // 1. The start check-in event + // 2. The finish check-in event + $this->assertSentryCheckInCount(2); + + $finishCheckInEvent = $this->getLastSentryEvent(); + + $this->assertNotNull($finishCheckInEvent->getCheckIn()); + $this->assertEquals('test-monitor', $finishCheckInEvent->getCheckIn()->getMonitorSlug()); + } + + /** + * When a timezone was defined on a command this would fail with: + * Sentry\MonitorConfig::__construct(): Argument #4 ($timezone) must be of type ?string, DateTimeZone given + * This test ensures that the timezone is properly converted to a string as expected. + */ + public function testScheduleMacroWithTimeZone(): void + { + $expectedTimezone = 'UTC'; + + /** @var Event $scheduledEvent */ + $scheduledEvent = $this->getScheduler() + ->call(function () {}) + ->timezone(new DateTimeZone($expectedTimezone)) + ->sentryMonitor('test-timezone-monitor'); + + $scheduledEvent->run($this->app); + + // We expect a total of 2 events to be sent to Sentry: + // 1. The start check-in event + // 2. The finish check-in event + $this->assertSentryCheckInCount(2); + + $finishCheckInEvent = $this->getLastSentryEvent(); + + $this->assertNotNull($finishCheckInEvent->getCheckIn()); + $this->assertEquals($expectedTimezone, $finishCheckInEvent->getCheckIn()->getMonitorConfig()->getTimezone()); + } + + public function testScheduleMacroAutomaticSlug(): void + { + /** @var Event $scheduledEvent */ + $scheduledEvent = $this->getScheduler()->command('inspire')->sentryMonitor(); + + $scheduledEvent->run($this->app); + + // We expect a total of 2 events to be sent to Sentry: + // 1. The start check-in event + // 2. The finish check-in event + $this->assertSentryCheckInCount(2); + + $finishCheckInEvent = $this->getLastSentryEvent(); + + $this->assertNotNull($finishCheckInEvent->getCheckIn()); + $this->assertEquals('scheduled_artisan-inspire', $finishCheckInEvent->getCheckIn()->getMonitorSlug()); + } + + public function testScheduleMacroWithoutSlugOrCommandName(): void + { + $this->expectException(RuntimeException::class); + + $this->getScheduler()->call(function () {})->sentryMonitor(); + } + + /** @define-env envWithoutDsnSet */ + public function testScheduleMacroWithoutDsnSet(): void + { + /** @var Event $scheduledEvent */ + $scheduledEvent = $this->getScheduler()->call(function () {})->sentryMonitor('test-monitor'); + + $scheduledEvent->run($this->app); + + $this->assertSentryCheckInCount(0); + } + + public function testScheduleMacroIsRegistered(): void + { + if (!method_exists(Event::class, 'flushMacros')) { + $this->markTestSkipped('Macroable::flushMacros() is not available in this Laravel version.'); + } + + Event::flushMacros(); + + $this->refreshApplication(); + + $this->assertTrue(Event::hasMacro('sentryMonitor')); + } + + /** @define-env envWithoutDsnSet */ + public function testScheduleMacroIsRegisteredWithoutDsnSet(): void + { + if (!method_exists(Event::class, 'flushMacros')) { + $this->markTestSkipped('Macroable::flushMacros() is not available in this Laravel version.'); + } + + Event::flushMacros(); + + $this->refreshApplication(); + + $this->assertTrue(Event::hasMacro('sentryMonitor')); + } + + private function getScheduler(): Schedule + { + return $this->app->make(Schedule::class); + } +} diff --git a/packages/laravel/test/Sentry/Features/DatabaseIntegrationTest.php b/packages/laravel/test/Sentry/Features/DatabaseIntegrationTest.php new file mode 100644 index 000000000000..9b445d2d8095 --- /dev/null +++ b/packages/laravel/test/Sentry/Features/DatabaseIntegrationTest.php @@ -0,0 +1,165 @@ +set('database.default', 'mysql'); + $app['config']->set('database.connections.mysql', [ + 'driver' => 'mysql', + 'host' => 'host-mysql', + 'port' => 3306, + 'username' => 'user-mysql', + 'password' => 'password', + 'database' => 'db-mysql', + ]); + } + + protected function usesMySQLFromUrl($app): void + { + $app['config']->set('database.default', 'mysqlurl'); + $app['config']->set('database.connections.mysqlurl', [ + 'driver' => 'mysql', + 'url' => 'mysql://user-mysqlurl:password@host-mysqlurl:3307/db-mysqlurl', + ]); + } + + protected function usesInMemorySqlite($app): void + { + $app['config']->set('database.default', 'inmemory'); + $app['config']->set('database.connections.inmemory', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + } + + /** + * @define-env usesMySQL + */ + public function testSpanIsCreatedForMySQLConnectionQuery(): void + { + $span = $this->executeQueryAndRetrieveSpan( + $query = 'SELECT "mysql"' + ); + + $this->assertEquals($query, $span->getDescription()); + $this->assertEquals('db.sql.query', $span->getOp()); + $this->assertEquals('host-mysql', $span->getData()['server.address']); + $this->assertEquals(3306, $span->getData()['server.port']); + } + + /** + * @define-env usesMySQLFromUrl + */ + public function testSpanIsCreatedForMySQLUrlConnectionQuery(): void + { + $span = $this->executeQueryAndRetrieveSpan( + $query = 'SELECT "mysqlurl"' + ); + + $this->assertEquals($query, $span->getDescription()); + $this->assertEquals('db.sql.query', $span->getOp()); + $this->assertEquals('host-mysqlurl', $span->getData()['server.address']); + $this->assertEquals(3307, $span->getData()['server.port']); + } + + /** + * @define-env usesInMemorySqlite + */ + public function testSpanIsCreatedForSqliteConnectionQuery(): void + { + $span = $this->executeQueryAndRetrieveSpan( + $query = 'SELECT "inmemory"' + ); + + $this->assertEquals($query, $span->getDescription()); + $this->assertEquals('db.sql.query', $span->getOp()); + $this->assertNull($span->getData()['server.address']); + $this->assertNull($span->getData()['server.port']); + } + + public function testSqlBindingsAreRecordedWhenEnabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_bindings' => true, + ]); + + $span = $this->executeQueryAndRetrieveSpan( + $query = 'SELECT %', + $bindings = ['1'] + ); + + $this->assertEquals($query, $span->getDescription()); + $this->assertEquals($bindings, $span->getData()['db.sql.bindings']); + } + + public function testSqlBindingsAreRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_bindings' => false, + ]); + + $span = $this->executeQueryAndRetrieveSpan( + $query = 'SELECT %', + ['1'] + ); + + $this->assertEquals($query, $span->getDescription()); + $this->assertFalse(isset($span->getData()['db.sql.bindings'])); + } + + public function testSqlOriginIsResolvedWhenEnabledAndOverTreshold(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_origin' => true, + 'sentry.tracing.sql_origin_threshold_ms' => 10, + ]); + + $span = $this->executeQueryAndRetrieveSpan('SELECT 1', [], 20); + + $this->assertArrayHasKey('code.filepath', $span->getData()); + } + + public function testSqlOriginIsNotResolvedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_origin' => false, + ]); + + $span = $this->executeQueryAndRetrieveSpan('SELECT 1'); + + $this->assertArrayNotHasKey('code.filepath', $span->getData()); + } + + public function testSqlOriginIsNotResolvedWhenUnderThreshold(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.sql_origin' => true, + 'sentry.tracing.sql_origin_threshold_ms' => 10, + ]); + + $span = $this->executeQueryAndRetrieveSpan('SELECT 1', [], 5); + + $this->assertArrayNotHasKey('code.filepath', $span->getData()); + } + + private function executeQueryAndRetrieveSpan(string $query, array $bindings = [], int $time = 123): Span + { + $transaction = $this->startTransaction(); + + $this->dispatchLaravelEvent(new QueryExecuted($query, $bindings, $time, DB::connection())); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + + return $spans[1]; + } +} diff --git a/packages/laravel/test/Sentry/Features/FolioPackageIntegrationTest.php b/packages/laravel/test/Sentry/Features/FolioPackageIntegrationTest.php new file mode 100644 index 000000000000..02be8ede74da --- /dev/null +++ b/packages/laravel/test/Sentry/Features/FolioPackageIntegrationTest.php @@ -0,0 +1,178 @@ +markTestSkipped('Laravel Folio package is not installed.'); + } + + parent::setUp(); + } + + protected function defineRoutes($router): void + { + $folioStubPath = __DIR__ . '/../../stubs/folio'; + + Folio::route($folioStubPath); + + Folio::path($folioStubPath)->uri('/folio'); + } + + protected function defineEnvironment($app): void + { + parent::defineEnvironment($app); + + tap($app['config'], static function (Repository $config) { + // This is done to prevent noise from the database queries in the breadcrumbs + $config->set('sentry.breadcrumbs.sql_queries', false); + + $config->set('database.default', 'inmemory'); + $config->set('database.connections.inmemory', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + }); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadLaravelMigrations(); + } + + /** @define-env envSamplingAllTransactions */ + public function testFolioCatchAllRouteCreatesTransaction(): void + { + $this->get('/')->assertOk(); + + $this->assertSentryTransactionCount(1); + + $transaction = $this->getLastSentryEvent(); + + $this->assertEquals('/index', $transaction->getTransaction()); + $this->assertEquals(EventType::transaction(), $transaction->getType()); + } + + /** @define-env envSamplingAllTransactions */ + public function testFolioCatchAllRouteWithoutHandlerDropsTransaction(): void + { + $this->get('/non-existing-route')->assertNotFound(); + + $this->assertSentryTransactionCount(0); + } + + /** @define-env envSamplingAllTransactions */ + public function testFolioCatchAllRouteThrowingNotFoundDropsTransaction(): void + { + $this->get('/user/420')->assertNotFound(); + + // Unfortunately it's not possible to detect a matching route since the Folio router bails early + // So even though the `/user/[id].blade.php` view exists we can't detect it and thus drop the transaction + $this->assertSentryTransactionCount(0); + } + + /** @define-env envSamplingAllTransactions */ + public function testFolioPathRouteCreatesTransaction(): void + { + $this->get('/folio')->assertOk(); + + $this->assertSentryTransactionCount(1); + + $transaction = $this->getLastSentryEvent(); + + $this->assertEquals('/folio/index', $transaction->getTransaction()); + $this->assertEquals(EventType::transaction(), $transaction->getType()); + } + + /** @define-env envSamplingAllTransactions */ + public function testFolioPathRouteWithoutHandlerDropsTransaction(): void + { + $this->get('/folio/non-existing-route')->assertNotFound(); + + $this->assertSentryTransactionCount(0); + } + + /** @define-env envSamplingAllTransactions */ + public function testFolioPathRouteThrowingNotFoundDropsTransaction(): void + { + $this->get('/folio/user/420')->assertNotFound(); + + // Unfortunately it's not possible to detect a matching route since the Folio router bails early + // So even though the `/user/[id].blade.php` view exists we can't detect it and thus drop the transaction + $this->assertSentryTransactionCount(0); + } + + public function testFolioBreadcrumbIsRecorded(): void + { + $this->get('/folio'); + + $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); + + $lastBreadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals('folio.route', $lastBreadcrumb->getCategory()); + $this->assertEquals('navigation', $lastBreadcrumb->getType()); + $this->assertEquals('/folio/index', $lastBreadcrumb->getMessage()); + } + + public function testFolioRouteUpdatesIntegrationTransaction(): void + { + $this->get('/folio/post/123')->assertOk(); + + $this->assertEquals('/folio/post/{id}', Integration::getTransaction()); + } + + public function testFolioRouteUpdatesPerformanceTransaction(): void + { + $transaction = $this->startTransaction(); + + $this->get('/folio/post/123')->assertOk(); + + $this->assertEquals('/folio/post/{id}', $transaction->getName()); + } + + public function testFolioTransactionNameForRouteWithSingleSegmentParamater(): void + { + $this->get('/folio/post/123')->assertOk(); + + $this->assertEquals('/folio/post/{id}', Integration::getTransaction()); + } + + public function testFolioTransactionNameForRouteWithMultipleSegmentParameter(): void + { + $this->get('/folio/posts/1/2/3')->assertOk(); + + $this->assertEquals('/folio/posts/{...ids}', Integration::getTransaction()); + } + + public function testFolioTransactionNameForRouteWithRouteModelBoundSegmentParameter(): void + { + $user = FolioPackageIntegrationUserModel::create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'secret', + ]); + + $this->get("/folio/user/{$user->id}")->assertOk(); + + // This looks a little odd, but that is because we want to make the route model binding work in our tests + // normally this would look like `/folio/user/{User}` instead, see: https://laravel.com/docs/10.x/folio#route-model-binding. + $this->assertEquals('/folio/user/{.Sentry.Features.FolioPackageIntegrationUserModel}', Integration::getTransaction()); + } +} + +class FolioPackageIntegrationUserModel extends Model +{ + protected $table = 'users'; + protected $guarded = false; +} diff --git a/packages/laravel/test/Sentry/Features/HttpClientIntegrationTest.php b/packages/laravel/test/Sentry/Features/HttpClientIntegrationTest.php new file mode 100644 index 000000000000..63907c8d4cdf --- /dev/null +++ b/packages/laravel/test/Sentry/Features/HttpClientIntegrationTest.php @@ -0,0 +1,154 @@ +markTestSkipped('The Laravel HTTP client events are only available in Laravel 8.0+'); + } + + parent::setUp(); + } + + public function testHttpClientBreadcrumbIsRecordedForResponseReceivedEvent(): void + { + $this->dispatchLaravelEvent(new ResponseReceived( + new Request(new PsrRequest('GET', 'https://example.com', [], 'request')), + new Response(new PsrResponse(200, [], 'response')) + )); + + $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); + + $metadata = $this->getLastSentryBreadcrumb()->getMetadata(); + + $this->assertEquals('GET', $metadata['http.request.method']); + $this->assertEquals('https://example.com', $metadata['url']); + $this->assertEquals(200, $metadata['http.response.status_code']); + $this->assertEquals(7, $metadata['http.request.body.size']); + $this->assertEquals(8, $metadata['http.response.body.size']); + } + + public function testHttpClientBreadcrumbDoesntConsumeBodyStream(): void + { + $this->dispatchLaravelEvent(new ResponseReceived( + $request = new Request(new PsrRequest('GET', 'https://example.com', [], 'request')), + $response = new Response(new PsrResponse(200, [], 'response')) + )); + + $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); + + $this->assertEquals('request', $request->toPsrRequest()->getBody()->getContents()); + $this->assertEquals('response', $response->toPsrResponse()->getBody()->getContents()); + } + + public function testHttpClientBreadcrumbIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.http_client_requests' => false, + ]); + + $this->dispatchLaravelEvent(new ResponseReceived( + new Request(new PsrRequest('GET', 'https://example.com', [], 'request')), + new Response(new PsrResponse(200, [], 'response')) + )); + + $this->assertEmpty($this->getCurrentSentryBreadcrumbs()); + } + + public function testHttpClientSpanIsRecorded(): void + { + $transaction = $this->startTransaction(); + + $client = Http::fake(); + + $client->get('https://example.com'); + + /** @var \Sentry\Tracing\Span $span */ + $span = last($transaction->getSpanRecorder()->getSpans()); + + $this->assertEquals('http.client', $span->getOp()); + $this->assertEquals('GET https://example.com', $span->getDescription()); + } + + public function testHttpClientSpanIsRecordedWithCorrectResult(): void + { + $transaction = $this->startTransaction(); + + $client = Http::fake([ + 'example.com/success' => Http::response('OK'), + 'example.com/error' => Http::response('Internal Server Error', 500), + ]); + + $client->get('https://example.com/success'); + + /** @var \Sentry\Tracing\Span $span */ + $span = last($transaction->getSpanRecorder()->getSpans()); + + $this->assertEquals('http.client', $span->getOp()); + $this->assertEquals(SpanStatus::ok(), $span->getStatus()); + + $client->get('https://example.com/error'); + + /** @var \Sentry\Tracing\Span $span */ + $span = last($transaction->getSpanRecorder()->getSpans()); + + $this->assertEquals('http.client', $span->getOp()); + $this->assertEquals(SpanStatus::internalError(), $span->getStatus()); + } + + public function testHttpClientSpanIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.http_client_requests' => false, + ]); + + $transaction = $this->startTransaction(); + + $client = Http::fake(); + + $client->get('https://example.com'); + + /** @var \Sentry\Tracing\Span $span */ + $span = last($transaction->getSpanRecorder()->getSpans()); + + $this->assertNotEquals('http.client', $span->getOp()); + } + + public function testHttpClientRequestTracingHeadersAreAttached(): void + { + if (!method_exists(Factory::class, 'globalRequestMiddleware')) { + $this->markTestSkipped('The `globalRequestMiddleware` functionality we rely on was introduced in Laravel 10.14'); + } + + $this->resetApplicationWithConfig([ + 'sentry.trace_propagation_targets' => ['example.com'], + ]); + + $client = Http::fake(); + + $client->get('https://example.com'); + + Http::assertSent(function (Request $request) { + return $request->hasHeader('baggage') && $request->hasHeader('sentry-trace'); + }); + + $client->get('https://no-headers.example.com'); + + Http::assertSent(function (Request $request) { + return !$request->hasHeader('baggage') && !$request->hasHeader('sentry-trace'); + }); + } +} diff --git a/packages/laravel/test/Sentry/Features/LogIntegrationTest.php b/packages/laravel/test/Sentry/Features/LogIntegrationTest.php new file mode 100644 index 000000000000..8c026fcac452 --- /dev/null +++ b/packages/laravel/test/Sentry/Features/LogIntegrationTest.php @@ -0,0 +1,72 @@ +set('logging.channels.sentry', [ + 'driver' => 'sentry', + ]); + + $config->set('logging.channels.sentry_error_level', [ + 'driver' => 'sentry', + 'level' => 'error', + ]); + }); + } + + public function testLogChannelIsRegistered(): void + { + $this->expectNotToPerformAssertions(); + + Log::channel('sentry'); + } + + /** @define-env envWithoutDsnSet */ + public function testLogChannelIsRegisteredWithoutDsn(): void + { + $this->expectNotToPerformAssertions(); + + Log::channel('sentry'); + } + + public function testLogChannelGeneratesEvents(): void + { + $logger = Log::channel('sentry'); + + $logger->info('Sentry Laravel info log message'); + + $this->assertSentryEventCount(1); + + $event = $this->getLastSentryEvent(); + + $this->assertEquals(Severity::info(), $event->getLevel()); + $this->assertEquals('Sentry Laravel info log message', $event->getMessage()); + } + + public function testLogChannelGeneratesEventsOnlyForConfiguredLevel(): void + { + $logger = Log::channel('sentry_error_level'); + + $logger->info('Sentry Laravel info log message'); + $logger->warning('Sentry Laravel warning log message'); + $logger->error('Sentry Laravel error log message'); + + $this->assertSentryEventCount(1); + + $event = $this->getLastSentryEvent(); + + $this->assertEquals(Severity::error(), $event->getLevel()); + $this->assertEquals('Sentry Laravel error log message', $event->getMessage()); + } +} diff --git a/packages/laravel/test/Sentry/Features/NotificationsIntegrationTest.php b/packages/laravel/test/Sentry/Features/NotificationsIntegrationTest.php new file mode 100644 index 000000000000..dfee35d53d12 --- /dev/null +++ b/packages/laravel/test/Sentry/Features/NotificationsIntegrationTest.php @@ -0,0 +1,102 @@ + false, + ]; + + public function testSpanIsRecorded(): void + { + $span = $this->sendNotificationAndRetrieveSpan(); + + $this->assertEquals('mail', $span->getDescription()); + $this->assertEquals('mail', $span->getData()['channel']); + $this->assertEquals('notification.send', $span->getOp()); + $this->assertEquals(SpanStatus::ok(), $span->getStatus()); + } + + public function testSpanIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.tracing.notifications.enabled' => false, + ]); + + $this->sendNotificationAndExpectNoSpan(); + } + + public function testBreadcrumbIsRecorded(): void + { + $this->sendTestNotification(); + + $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); + + $breadcrumb = $this->getLastSentryBreadcrumb(); + + $this->assertEquals('notification.sent', $breadcrumb->getCategory()); + } + + public function testBreadcrumbIsNotRecordedWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.breadcrumbs.notifications.enabled' => false, + ]); + + $this->sendTestNotification(); + + $this->assertCount(0, $this->getCurrentSentryBreadcrumbs()); + } + + private function sendTestNotification(): void + { + // We fake the mail so that no actual email is sent but the notification is still sent with all it's events + Mail::fake(); + + Notification::route('mail', 'sentry@example.com')->notifyNow(new NotificationsIntegrationTestNotification); + } + + private function sendNotificationAndRetrieveSpan(): Span + { + $transaction = $this->startTransaction(); + + $this->sendTestNotification(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + + return $spans[1]; + } + + private function sendNotificationAndExpectNoSpan(): void + { + $transaction = $this->startTransaction(); + + $this->sendTestNotification(); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(1, $spans); + } +} + +class NotificationsIntegrationTestNotification extends \Illuminate\Notifications\Notification +{ + public function via($notifiable) + { + return ['mail']; + } + + public function toMail($notifiable) + { + return new \Illuminate\Notifications\Messages\MailMessage; + } +} diff --git a/packages/laravel/test/Sentry/Features/QueueIntegrationTest.php b/packages/laravel/test/Sentry/Features/QueueIntegrationTest.php new file mode 100644 index 000000000000..9feb327afaba --- /dev/null +++ b/packages/laravel/test/Sentry/Features/QueueIntegrationTest.php @@ -0,0 +1,185 @@ +assertCount(0, $this->getCurrentSentryBreadcrumbs()); + } + + public function testQueueJobThatReportsPushesAndPopsScopeWithBreadcrumbs(): void + { + dispatch(new QueueEventsTestJobThatReportsAnExceptionWithBreadcrumb); + + $this->assertCount(0, $this->getCurrentSentryBreadcrumbs()); + + $this->assertNotNull($this->getLastSentryEvent()); + + $event = $this->getLastSentryEvent(); + + $this->assertCount(2, $event->getBreadcrumbs()); + } + + public function testQueueJobThatThrowsLeavesPushedScopeWithBreadcrumbs(): void + { + try { + dispatch(new QueueEventsTestJobThatThrowsAnUnhandledExceptionWithBreadcrumb); + } catch (Exception $e) { + // No action required, expected to throw + } + + // We still expect to find the breadcrumbs from the job here so they are attached to reported exceptions + + $this->assertCount(2, $this->getCurrentSentryBreadcrumbs()); + + $firstBreadcrumb = $this->getCurrentSentryBreadcrumbs()[0]; + $this->assertEquals('queue.job', $firstBreadcrumb->getCategory()); + + $secondBreadcrumb = $this->getCurrentSentryBreadcrumbs()[1]; + $this->assertEquals('test', $secondBreadcrumb->getCategory()); + } + + public function testQueueJobsThatThrowPopsAndPushesScopeWithBreadcrumbsBeforeNewJob(): void + { + try { + dispatch(new QueueEventsTestJobThatThrowsAnUnhandledExceptionWithBreadcrumb('test #1')); + } catch (Exception $e) { + // No action required, expected to throw + } + + try { + dispatch(new QueueEventsTestJobThatThrowsAnUnhandledExceptionWithBreadcrumb('test #2')); + } catch (Exception $e) { + // No action required, expected to throw + } + + // We only expect to find the breadcrumbs from the second job here + + $this->assertCount(2, $this->getCurrentSentryBreadcrumbs()); + + $firstBreadcrumb = $this->getCurrentSentryBreadcrumbs()[0]; + $this->assertEquals('queue.job', $firstBreadcrumb->getCategory()); + + $secondBreadcrumb = $this->getCurrentSentryBreadcrumbs()[1]; + $this->assertEquals('test #2', $secondBreadcrumb->getMessage()); + } + + public function testQueueJobsWithBreadcrumbSetInBetweenKeepsNonJobBreadcrumbsOnCurrentScope(): void + { + dispatch(new QueueEventsTestJobWithBreadcrumb); + + addBreadcrumb(new Breadcrumb(Breadcrumb::LEVEL_INFO, Breadcrumb::LEVEL_DEBUG, 'test2', 'test2')); + + dispatch(new QueueEventsTestJobWithBreadcrumb); + + $this->assertCount(1, $this->getCurrentSentryBreadcrumbs()); + } + + protected function withTracingEnabled($app): void + { + $app['config']->set('sentry.traces_sample_rate', 1.0); + } + + /** + * @define-env withTracingEnabled + */ + public function testQueueJobCreatesTransactionByDefault(): void + { + dispatch(new QueueEventsTestJob); + + $transaction = $this->getLastSentryEvent(); + + $this->assertNotNull($transaction); + + $this->assertEquals(EventType::transaction(), $transaction->getType()); + $this->assertEquals(QueueEventsTestJob::class, $transaction->getTransaction()); + + $traceContext = $transaction->getContexts()['trace']; + + $this->assertEquals('queue.process', $traceContext['op']); + } + + protected function withQueueJobTracingDisabled($app): void + { + $app['config']->set('sentry.traces_sample_rate', 1.0); + $app['config']->set('sentry.tracing.queue_job_transactions', false); + } + + /** + * @define-env withQueueTracingDisabled + */ + public function testQueueJobDoesntCreateTransaction(): void + { + dispatch(new QueueEventsTestJob); + + $transaction = $this->getLastSentryEvent(); + + $this->assertNull($transaction); + } +} + +class QueueEventsTestJob implements ShouldQueue +{ + public function handle(): void + { + } +} + +function queueEventsTestAddTestBreadcrumb($message = null): void +{ + addBreadcrumb( + new Breadcrumb( + Breadcrumb::LEVEL_INFO, + Breadcrumb::LEVEL_DEBUG, + 'test', + $message ?? 'test' + ) + ); +} + +class QueueEventsTestJobWithBreadcrumb implements ShouldQueue +{ + public function handle(): void + { + queueEventsTestAddTestBreadcrumb(); + } +} + +class QueueEventsTestJobThatReportsAnExceptionWithBreadcrumb implements ShouldQueue +{ + public function handle(): void + { + queueEventsTestAddTestBreadcrumb(); + + captureException(new Exception('This is a test exception')); + } +} + +class QueueEventsTestJobThatThrowsAnUnhandledExceptionWithBreadcrumb implements ShouldQueue +{ + private $message; + + public function __construct($message = null) + { + $this->message = $message; + } + + public function handle(): void + { + queueEventsTestAddTestBreadcrumb($this->message); + + throw new Exception('This is a test exception'); + } +} diff --git a/packages/laravel/test/Sentry/Features/RouteIntegrationTest.php b/packages/laravel/test/Sentry/Features/RouteIntegrationTest.php new file mode 100644 index 000000000000..e4b99860ed4c --- /dev/null +++ b/packages/laravel/test/Sentry/Features/RouteIntegrationTest.php @@ -0,0 +1,46 @@ +group(['prefix' => 'sentry'], function (Router $router) { + $router->get('/ok', function () { + return 'ok'; + }); + + $router->get('/abort/{code}', function (int $code) { + abort($code); + }); + }); + } + + /** @define-env envSamplingAllTransactions */ + public function testTransactionIsRecordedForRoute(): void + { + $this->get('/sentry/ok')->assertOk(); + + $this->assertSentryTransactionCount(1); + } + + /** @define-env envSamplingAllTransactions */ + public function testTransactionIsRecordedForNotFound(): void + { + $this->get('/sentry/abort/404')->assertNotFound(); + + $this->assertSentryTransactionCount(1); + } + + /** @define-env envSamplingAllTransactions */ + public function testTransactionIsDroppedForUndefinedRoute(): void + { + $this->get('/sentry/non-existent-route')->assertNotFound(); + + $this->assertSentryTransactionCount(0); + } +} diff --git a/packages/laravel/test/Sentry/Features/StorageIntegrationTest.php b/packages/laravel/test/Sentry/Features/StorageIntegrationTest.php new file mode 100644 index 000000000000..2aa0e1241870 --- /dev/null +++ b/packages/laravel/test/Sentry/Features/StorageIntegrationTest.php @@ -0,0 +1,211 @@ +resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + $transaction = $this->startTransaction(); + + Storage::put('foo', 'bar'); + $fooContent = Storage::get('foo'); + Storage::assertExists('foo', 'bar'); + Storage::delete('foo'); + Storage::delete(['foo', 'bar']); + Storage::files(); + Storage::assertMissing(['foo', 'bar']); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertArrayHasKey(1, $spans); + $span = $spans[1]; + $this->assertSame('file.put', $span->getOp()); + $this->assertSame('foo (3 B)', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'options' => [], 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(2, $spans); + $span = $spans[2]; + $this->assertSame('file.get', $span->getOp()); + $this->assertSame('foo', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getData()); + $this->assertSame('bar', $fooContent); + + $this->assertArrayHasKey(3, $spans); + $span = $spans[3]; + $this->assertSame('file.assertExists', $span->getOp()); + $this->assertSame('foo', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(4, $spans); + $span = $spans[4]; + $this->assertSame('file.delete', $span->getOp()); + $this->assertSame('foo', $span->getDescription()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(5, $spans); + $span = $spans[5]; + $this->assertSame('file.delete', $span->getOp()); + $this->assertSame('2 paths', $span->getDescription()); + $this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(6, $spans); + $span = $spans[6]; + $this->assertSame('file.files', $span->getOp()); + $this->assertNull($span->getDescription()); + $this->assertSame(['directory' => null, 'recursive' => false, 'disk' => 'local', 'driver' => 'local'], $span->getData()); + + $this->assertArrayHasKey(7, $spans); + $span = $spans[7]; + $this->assertSame('file.assertMissing', $span->getOp()); + $this->assertSame('2 paths', $span->getDescription()); + $this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getData()); + } + + public function testDoesntCreateSpansWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks'), false), + ]); + + $transaction = $this->startTransaction(); + + Storage::exists('foo'); + + $this->assertCount(1, $transaction->getSpanRecorder()->getSpans()); + } + + public function testCreatesBreadcrumbsFor(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + Storage::put('foo', 'bar'); + $fooContent = Storage::get('foo'); + Storage::assertExists('foo', 'bar'); + Storage::delete('foo'); + Storage::delete(['foo', 'bar']); + Storage::files(); + + $breadcrumbs = $this->getCurrentSentryBreadcrumbs(); + + $this->assertArrayHasKey(0, $breadcrumbs); + $span = $breadcrumbs[0]; + $this->assertSame('file.put', $span->getCategory()); + $this->assertSame('foo (3 B)', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'options' => [], 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(1, $breadcrumbs); + $span = $breadcrumbs[1]; + $this->assertSame('file.get', $span->getCategory()); + $this->assertSame('foo', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + $this->assertSame('bar', $fooContent); + + $this->assertArrayHasKey(2, $breadcrumbs); + $span = $breadcrumbs[2]; + $this->assertSame('file.assertExists', $span->getCategory()); + $this->assertSame('foo', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(3, $breadcrumbs); + $span = $breadcrumbs[3]; + $this->assertSame('file.delete', $span->getCategory()); + $this->assertSame('foo', $span->getMessage()); + $this->assertSame(['path' => 'foo', 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(4, $breadcrumbs); + $span = $breadcrumbs[4]; + $this->assertSame('file.delete', $span->getCategory()); + $this->assertSame('2 paths', $span->getMessage()); + $this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + + $this->assertArrayHasKey(5, $breadcrumbs); + $span = $breadcrumbs[5]; + $this->assertSame('file.files', $span->getCategory()); + $this->assertNull($span->getMessage()); + $this->assertSame(['directory' => null, 'recursive' => false, 'disk' => 'local', 'driver' => 'local'], $span->getMetadata()); + } + + public function testDoesntCreateBreadcrumbsWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks'), true, false), + ]); + + Storage::exists('foo'); + + $this->assertCount(0, $this->getCurrentSentryBreadcrumbs()); + } + + public function testDriverWorksWhenDisabled(): void + { + $this->resetApplicationWithConfig([ + 'sentry.dsn' => null, + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + Storage::exists('foo'); + + $this->expectNotToPerformAssertions(); + } + + public function testResolvingDiskDoesNotModifyConfig(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks(config('filesystems.disks')), + ]); + + $originalConfig = config('filesystems.disks.local'); + + Storage::disk('local'); + + $this->assertEquals($originalConfig, config('filesystems.disks.local')); + } + + public function testThrowsIfDiskConfigurationDoesntSpecifyDiskName(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local.driver' => 'sentry', + 'filesystems.disks.local.sentry_original_driver' => 'local', + ]); + + $this->expectExceptionMessage('Missing `sentry_disk_name` config key for `sentry` filesystem driver.'); + + Storage::disk('local'); + } + + public function testThrowsIfDiskConfigurationDoesntSpecifyOriginalDriver(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local.driver' => 'sentry', + 'filesystems.disks.local.sentry_disk_name' => 'local', + ]); + + $this->expectExceptionMessage('Missing `sentry_original_driver` config key for `sentry` filesystem driver.'); + + Storage::disk('local'); + } + + public function testThrowsIfDiskConfigurationCreatesCircularReference(): void + { + $this->resetApplicationWithConfig([ + 'filesystems.disks.local.driver' => 'sentry', + 'filesystems.disks.local.sentry_disk_name' => 'local', + 'filesystems.disks.local.sentry_original_driver' => 'sentry', + ]); + + $this->expectExceptionMessage('`sentry_original_driver` for Sentry storage integration cannot be the `sentry` driver.'); + + Storage::disk('local'); + } +} diff --git a/packages/laravel/test/Sentry/Http/LaravelRequestFetcherTest.php b/packages/laravel/test/Sentry/Http/LaravelRequestFetcherTest.php new file mode 100644 index 000000000000..a115d545fa40 --- /dev/null +++ b/packages/laravel/test/Sentry/Http/LaravelRequestFetcherTest.php @@ -0,0 +1,27 @@ +get('/', function () { + return 'Hello!'; + }); + } + + public function testPsr7InstanceCanBeResolved(): void + { + // The request is only set on the container if we made a request (it is resolved by the SetRequestMiddleware) + $this->get('/'); + + $instance = $this->app->make(LaravelRequestFetcher::CONTAINER_PSR7_INSTANCE_KEY); + + $this->assertInstanceOf(ServerRequest::class, $instance); + } +} diff --git a/packages/laravel/test/Sentry/Integration/ExceptionContextIntegrationTest.php b/packages/laravel/test/Sentry/Integration/ExceptionContextIntegrationTest.php new file mode 100644 index 000000000000..27f0a118d329 --- /dev/null +++ b/packages/laravel/test/Sentry/Integration/ExceptionContextIntegrationTest.php @@ -0,0 +1,78 @@ +getSentryHubFromContainer()->getIntegration(ExceptionContextIntegration::class); + + $this->assertInstanceOf(ExceptionContextIntegration::class, $integration); + } + + /** + * @dataProvider invokeDataProvider + */ + public function testInvoke(Exception $exception, ?array $expectedContext): void + { + withScope(function (Scope $scope) use ($exception, $expectedContext): void { + $event = Event::createEvent(); + + $event = $scope->applyToEvent($event, EventHint::fromArray(compact('exception'))); + + $this->assertNotNull($event); + + $exceptionContext = $event->getExtra()['exception_context'] ?? null; + + $this->assertSame($expectedContext, $exceptionContext); + }); + } + + public static function invokeDataProvider(): iterable + { + yield 'Exception without context method -> no exception context' => [ + new Exception('Exception without context.'), + null, + ]; + + $context = ['some' => 'context']; + + yield 'Exception with context method returning array of context' => [ + self::generateExceptionWithContext($context), + $context, + ]; + + yield 'Exception with context method returning string of context' => [ + self::generateExceptionWithContext('Invalid context, expects array'), + null, + ]; + } + + private static function generateExceptionWithContext($context): Exception + { + return new class($context) extends Exception { + private $context; + + public function __construct($context) + { + $this->context = $context; + + parent::__construct('Exception with context.'); + } + + public function context() + { + return $this->context; + } + }; + } +} diff --git a/packages/laravel/test/Sentry/Integration/LaravelContextIntegrationTest.php b/packages/laravel/test/Sentry/Integration/LaravelContextIntegrationTest.php new file mode 100644 index 000000000000..042f3b0d95c5 --- /dev/null +++ b/packages/laravel/test/Sentry/Integration/LaravelContextIntegrationTest.php @@ -0,0 +1,96 @@ +markTestSkipped('Laravel introduced contexts in version 11.'); + } + + parent::setUp(); + } + + public function testLaravelContextIntegrationIsRegistered(): void + { + $integration = $this->getSentryHubFromContainer()->getIntegration(LaravelContextIntegration::class); + + $this->assertInstanceOf(LaravelContextIntegration::class, $integration); + } + + public function testExceptionIsCapturedWithLaravelContext(): void + { + $this->setupTestContext(); + + captureException(new Exception('Context test')); + + $event = $this->getLastSentryEvent(); + + $this->assertNotNull($event); + $this->assertEquals($event->getType(), EventType::event()); + $this->assertContextIsCaptured($event->getContexts()); + } + + public function testExceptionIsCapturedWithoutLaravelContextIfEmpty(): void + { + captureException(new Exception('Context test')); + + $event = $this->getLastSentryEvent(); + + $this->assertNotNull($event); + $this->assertEquals($event->getType(), EventType::event()); + $this->assertArrayNotHasKey('laravel', $event->getContexts()); + } + + public function testExceptionIsCapturedWithoutLaravelContextIfOnlyHidden(): void + { + Context::addHidden('hidden', 'value'); + + captureException(new Exception('Context test')); + + $event = $this->getLastSentryEvent(); + + $this->assertNotNull($event); + $this->assertEquals($event->getType(), EventType::event()); + $this->assertArrayNotHasKey('laravel', $event->getContexts()); + } + + public function testTransactionIsCapturedWithLaravelContext(): void + { + $this->setupTestContext(); + + $transaction = $this->startTransaction(); + $transaction->setSampled(true); + $transaction->finish(); + + $event = $this->getLastSentryEvent(); + + $this->assertNotNull($event); + $this->assertEquals($event->getType(), EventType::transaction()); + $this->assertContextIsCaptured($event->getContexts()); + } + + private function setupTestContext(): void + { + Context::flush(); + Context::add('foo', 'bar'); + Context::addHidden('hidden', 'value'); + } + + private function assertContextIsCaptured(array $context): void + { + $this->assertArrayHasKey('laravel', $context); + $this->assertArrayHasKey('foo', $context['laravel']); + $this->assertArrayNotHasKey('hidden', $context['laravel']); + $this->assertEquals('bar', $context['laravel']['foo']); + } +} diff --git a/packages/laravel/test/Sentry/Integration/ModelViolationReportersTest.php b/packages/laravel/test/Sentry/Integration/ModelViolationReportersTest.php new file mode 100644 index 000000000000..3900238440e6 --- /dev/null +++ b/packages/laravel/test/Sentry/Integration/ModelViolationReportersTest.php @@ -0,0 +1,100 @@ +markTestSkipped('Laravel introduced model violations in version 9.'); + } + + parent::setUp(); + } + + public function testModelViolationReportersCanBeRegistered(): void + { + $this->expectNotToPerformAssertions(); + + Model::handleLazyLoadingViolationUsing(Integration::lazyLoadingViolationReporter()); + Model::handleMissingAttributeViolationUsing(Integration::missingAttributeViolationReporter()); + Model::handleDiscardedAttributeViolationUsing(Integration::discardedAttributeViolationReporter()); + } + + public function testViolationReporterAcceptsSingleProperty(): void + { + $reporter = Integration::discardedAttributeViolationReporter(null, true, false); + + $reporter(new ViolationReporterTestModel, 'foo'); + + $this->assertCount(1, $this->getCapturedSentryEvents()); + + $violation = $this->getLastSentryEvent()->getContexts()['violation']; + + $this->assertSame('foo', $violation['attribute']); + $this->assertSame('discarded_attribute', $violation['kind']); + $this->assertSame(ViolationReporterTestModel::class, $violation['model']); + } + + public function testViolationReporterAcceptsListOfProperties(): void + { + $reporter = Integration::discardedAttributeViolationReporter(null, true, false); + + $reporter(new ViolationReporterTestModel, ['foo', 'bar']); + + $this->assertCount(1, $this->getCapturedSentryEvents()); + + $violation = $this->getLastSentryEvent()->getContexts()['violation']; + + $this->assertSame('foo, bar', $violation['attribute']); + $this->assertSame('discarded_attribute', $violation['kind']); + $this->assertSame(ViolationReporterTestModel::class, $violation['model']); + } + + public function testViolationReporterPassesThroughToCallback(): void + { + $callbackCalled = false; + + $reporter = Integration::missingAttributeViolationReporter(static function () use (&$callbackCalled) { + $callbackCalled = true; + }, false, false); + + $reporter(new ViolationReporterTestModel, 'attribute'); + + $this->assertTrue($callbackCalled); + } + + public function testViolationReporterIsNotReportingDuplicateEvents(): void + { + $reporter = Integration::missingAttributeViolationReporter(null, true, false); + + $reporter(new ViolationReporterTestModel, 'attribute'); + $reporter(new ViolationReporterTestModel, 'attribute'); + + $this->assertCount(1, $this->getCapturedSentryEvents()); + } + + public function testViolationReporterIsReportingDuplicateEventsIfConfigured(): void + { + $reporter = Integration::missingAttributeViolationReporter(null, false, false); + + $reporter(new ViolationReporterTestModel, 'attribute'); + $reporter(new ViolationReporterTestModel, 'attribute'); + + $this->assertCount(2, $this->getCapturedSentryEvents()); + } +} + +class ViolationReporterTestModel extends Model +{ +} diff --git a/packages/laravel/test/Sentry/IntegrationTest.php b/packages/laravel/test/Sentry/IntegrationTest.php new file mode 100644 index 000000000000..fee6ac0aad65 --- /dev/null +++ b/packages/laravel/test/Sentry/IntegrationTest.php @@ -0,0 +1,195 @@ +getSentryHubFromContainer()->getIntegration(Integration::class); + + $this->assertInstanceOf(Integration::class, $integration); + } + + public function testTransactionIsSetWhenRouteMatchedEventIsFired(): void + { + Integration::setTransaction(null); + + $event = new RouteMatched( + new Route('GET', $routeUrl = '/sentry-route-matched-event', []), + Mockery::mock(Request::class)->makePartial() + ); + + $this->dispatchLaravelEvent($event); + + $this->assertSame($routeUrl, Integration::getTransaction()); + } + + public function testTransactionIsAppliedToEventWithoutTransaction(): void + { + Integration::setTransaction($transaction = 'some-transaction-name'); + + withScope(function (Scope $scope) use ($transaction): void { + $event = Event::createEvent(); + + $this->assertNull($event->getTransaction()); + + $event = $scope->applyToEvent($event); + + $this->assertNotNull($event); + + $this->assertSame($transaction, $event->getTransaction()); + }); + } + + public function testTransactionIsAppliedToEventWithEmptyTransaction(): void + { + Integration::setTransaction($transaction = 'some-transaction-name'); + + withScope(function (Scope $scope) use ($transaction): void { + $event = Event::createEvent(); + $event->setTransaction($emptyTransaction = ''); + + $this->assertSame($emptyTransaction, $event->getTransaction()); + + $event = $scope->applyToEvent($event); + + $this->assertNotNull($event); + + $this->assertSame($transaction, $event->getTransaction()); + }); + } + + public function testTransactionIsNotAppliedToEventWhenTransactionIsAlreadySet(): void + { + Integration::setTransaction('some-transaction-name'); + + withScope(function (Scope $scope): void { + $event = Event::createEvent(); + + $event->setTransaction($eventTransaction = 'some-other-transaction-name'); + + $this->assertSame($eventTransaction, $event->getTransaction()); + + $event = $scope->applyToEvent($event); + + $this->assertNotNull($event); + + $this->assertSame($eventTransaction, $event->getTransaction()); + }); + } + + public function testExtractingNameForRouteWithoutName(): void + { + $route = new Route('GET', $url = '/foo', []); + + $this->assertRouteNameAndSource($route, $url, TransactionSource::route()); + } + + public function testExtractingNameForRouteWithAutoGeneratedName(): void + { + // We fake a generated name here, Laravel generates them each starting with `generated::` + $route = (new Route('GET', $url = '/foo', []))->name('generated::KoAePbpBofo01ey4'); + + $this->assertRouteNameAndSource($route, $url, TransactionSource::route()); + } + + public function testExtractingNameForRouteWithIncompleteGroupName(): void + { + $route = (new Route('GET', $url = '/foo', []))->name('group-name.'); + + $this->assertRouteNameAndSource($route, $url, TransactionSource::route()); + } + + public function testExtractingNameForLumenRouteWithoutName(): void + { + $url = '/some-route'; + + $this->assertLumenRouteNameAndSource([0, [], []], $url, $url, TransactionSource::route()); + } + + public function testExtractingNameForLumenRouteWithParamInUrl(): void + { + $route = [1, [], ['param1' => 'foo']]; + + $url = '/foo/bar/baz'; + + $this->assertLumenRouteNameAndSource($route, $url, '/{param1}/bar/baz', TransactionSource::route()); + } + + public function testExtractingNameForLumenRouteWithParamsInUrl(): void + { + $route = [1, [], ['param1' => 'foo', 'param2' => 'bar']]; + + $url = '/foo/bar/baz'; + + $this->assertLumenRouteNameAndSource($route, $url, '/{param1}/{param2}/baz', TransactionSource::route()); + } + + public function testExtractingNameForLumenRouteWithParamsWithSameValueInUrl(): void + { + $route = [1, [], ['param1' => 'foo', 'param2' => 'foo']]; + + $url = '/foo/foo/bar'; + + $this->assertLumenRouteNameAndSource($route, $url, '/{param1}/{param2}/bar', TransactionSource::route()); + } + + public function testExceptionReportedUsingReportHelperIsNotMarkedAsUnhandled(): void + { + $testException = new RuntimeException('This was handled'); + + report($testException); + + $this->assertSentryEventCount(1); + + $hint = $this->getLastEventSentryHint(); + + $this->assertEquals($testException, $hint->exception); + $this->assertNotNull($hint->mechanism); + $this->assertTrue($hint->mechanism->isHandled()); + } + + public function testExceptionIsNotMarkedAsUnhandled(): void + { + $testException = new RuntimeException('This was not handled'); + + Integration::captureUnhandledException($testException); + + $this->assertSentryEventCount(1); + + $hint = $this->getLastEventSentryHint(); + + $this->assertEquals($testException, $hint->exception); + $this->assertNotNull($hint->mechanism); + $this->assertFalse($hint->mechanism->isHandled()); + } + + private function assertRouteNameAndSource(Route $route, string $expectedName, TransactionSource $expectedSource): void + { + [$actualName, $actualSource] = Integration::extractNameAndSourceForRoute($route); + + $this->assertSame($expectedName, $actualName); + $this->assertSame($expectedSource, $actualSource); + } + + private function assertLumenRouteNameAndSource(array $routeData, string $path, string $expectedName, TransactionSource $expectedSource): void + { + [$actualName, $actualSource] = Integration::extractNameAndSourceForLumenRoute($routeData, $path); + + $this->assertSame($expectedName, $actualName); + $this->assertSame($expectedSource, $actualSource); + } +} diff --git a/packages/laravel/test/Sentry/Laravel/LaravelContainerConfigOptionsTest.php b/packages/laravel/test/Sentry/Laravel/LaravelContainerConfigOptionsTest.php new file mode 100644 index 000000000000..94037e145331 --- /dev/null +++ b/packages/laravel/test/Sentry/Laravel/LaravelContainerConfigOptionsTest.php @@ -0,0 +1,28 @@ +getClient()->getOptions()->getLogger(); + + $this->assertNull($logger); + } + + public function testLoggerIsResolvedFromDefaultSingleton(): void + { + $this->resetApplicationWithConfig([ + 'sentry.logger' => DebugFileLogger::class, + ]); + + $logger = app(HubInterface::class)->getClient()->getOptions()->getLogger(); + + $this->assertInstanceOf(DebugFileLogger::class, $logger); + } +} diff --git a/packages/laravel/test/Sentry/Laravel/LaravelIntegrationsConfigOptionTest.php b/packages/laravel/test/Sentry/Laravel/LaravelIntegrationsConfigOptionTest.php new file mode 100644 index 000000000000..d604d5c6a181 --- /dev/null +++ b/packages/laravel/test/Sentry/Laravel/LaravelIntegrationsConfigOptionTest.php @@ -0,0 +1,112 @@ +singleton('custom-sentry-integration', static function () { + return new IntegrationsOptionTestIntegrationStub; + }); + } + + public function testCustomIntegrationIsResolvedFromContainerByAlias(): void + { + $this->resetApplicationWithConfig([ + 'sentry.integrations' => [ + 'custom-sentry-integration', + ], + ]); + + $this->assertNotNull($this->getSentryClientFromContainer()->getIntegration(IntegrationsOptionTestIntegrationStub::class)); + } + + public function testCustomIntegrationIsResolvedFromContainerByClass(): void + { + $this->resetApplicationWithConfig([ + 'sentry.integrations' => [ + IntegrationsOptionTestIntegrationStub::class, + ], + ]); + + $this->assertNotNull($this->getSentryClientFromContainer()->getIntegration(IntegrationsOptionTestIntegrationStub::class)); + } + + public function testCustomIntegrationByInstance(): void + { + $this->resetApplicationWithConfig([ + 'sentry.integrations' => [ + new IntegrationsOptionTestIntegrationStub, + ], + ]); + + $this->assertNotNull($this->getSentryClientFromContainer()->getIntegration(IntegrationsOptionTestIntegrationStub::class)); + } + + public function testCustomIntegrationThrowsExceptionIfNotResolvable(): void + { + $this->expectException(BindingResolutionException::class); + + $this->resetApplicationWithConfig([ + 'sentry.integrations' => [ + 'this-will-not-resolve', + ], + ]); + } + + public function testIncorrectIntegrationEntryThrowsException(): void + { + $this->expectException(RuntimeException::class); + + $this->resetApplicationWithConfig([ + 'sentry.integrations' => [ + static function () { + }, + ], + ]); + } + + public function testDisabledIntegrationsAreNotPresent(): void + { + $client = $this->getSentryClientFromContainer(); + + $this->assertNull($client->getIntegration(ErrorListenerIntegration::class)); + $this->assertNull($client->getIntegration(ExceptionListenerIntegration::class)); + $this->assertNull($client->getIntegration(FatalErrorListenerIntegration::class)); + } + + public function testDisabledIntegrationsAreNotPresentWithCustomIntegrations(): void + { + $this->resetApplicationWithConfig([ + 'sentry.integrations' => [ + new IntegrationsOptionTestIntegrationStub, + ], + ]); + + $client = $this->getSentryClientFromContainer(); + + $this->assertNotNull($client->getIntegration(IntegrationsOptionTestIntegrationStub::class)); + + $this->assertNull($client->getIntegration(ErrorListenerIntegration::class)); + $this->assertNull($client->getIntegration(ExceptionListenerIntegration::class)); + $this->assertNull($client->getIntegration(FatalErrorListenerIntegration::class)); + } +} + +class IntegrationsOptionTestIntegrationStub implements IntegrationInterface +{ + public function setupOnce(): void + { + } +} diff --git a/packages/laravel/test/Sentry/LogChannelTest.php b/packages/laravel/test/Sentry/LogChannelTest.php new file mode 100644 index 000000000000..fe3e9d15c1a7 --- /dev/null +++ b/packages/laravel/test/Sentry/LogChannelTest.php @@ -0,0 +1,118 @@ +app); + + $logger = $logChannel(); + + $this->assertContainsOnlyInstancesOf(SentryHandler::class, $logger->getHandlers()); + } + + public function testCreatingHandlerWithActionLevelConfig(): void + { + $logChannel = new LogChannel($this->app); + + $logger = $logChannel(['action_level' => 'critical']); + + $this->assertContainsOnlyInstancesOf(FingersCrossedHandler::class, $logger->getHandlers()); + + $currentHandler = current($logger->getHandlers()); + + if (method_exists($currentHandler, 'getHandler')) { + $this->assertInstanceOf(SentryHandler::class, $currentHandler->getHandler()); + } + + $loggerWithoutActionLevel = $logChannel(['action_level' => null]); + + $this->assertContainsOnlyInstancesOf(SentryHandler::class, $loggerWithoutActionLevel->getHandlers()); + } + + /** + * @dataProvider handlerDataProvider + */ + public function testHandlerWritingExpectedEventsAndContext(array $context, callable $asserter): void + { + $logChannel = new LogChannel($this->app); + + $logger = $logChannel(); + + $logger->error('test message', $context); + + $lastEvent = $this->getLastSentryEvent(); + + $this->assertNotNull($lastEvent); + $this->assertEquals('test message', $lastEvent->getMessage()); + $this->assertEquals('error', $lastEvent->getLevel()); + + $asserter($lastEvent); + } + + public static function handlerDataProvider(): iterable + { + $context = ['foo' => 'bar']; + + yield [ + $context, + function (Event $event) use ($context) { + self::assertEquals($context, $event->getExtra()['log_context']); + }, + ]; + + $context = ['fingerprint' => ['foo', 'bar']]; + + yield [ + $context, + function (Event $event) use ($context) { + self::assertEquals($context['fingerprint'], $event->getFingerprint()); + self::assertEmpty($event->getExtra()); + }, + ]; + + $context = ['user' => 'invalid value']; + + yield [ + $context, + function (Event $event) use ($context) { + self::assertNull($event->getUser()); + self::assertEquals($context, $event->getExtra()['log_context']); + }, + ]; + + $context = ['user' => ['id' => 123]]; + + yield [ + $context, + function (Event $event) { + self::assertNotNull($event->getUser()); + self::assertEquals(123, $event->getUser()->getId()); + self::assertEmpty($event->getExtra()); + }, + ]; + + $context = ['tags' => [ + 'foo' => 'bar', + 'bar' => 123, + ]]; + + yield [ + $context, + function (Event $event) { + self::assertSame([ + 'foo' => 'bar', + 'bar' => '123', + ], $event->getTags()); + self::assertEmpty($event->getExtra()); + }, + ]; + } +} diff --git a/packages/laravel/test/Sentry/ServiceProviderTest.php b/packages/laravel/test/Sentry/ServiceProviderTest.php new file mode 100644 index 000000000000..fa720160e68c --- /dev/null +++ b/packages/laravel/test/Sentry/ServiceProviderTest.php @@ -0,0 +1,80 @@ +set('sentry.dsn', 'https://publickey@sentry.dev/123'); + $app['config']->set('sentry.error_types', E_ALL ^ E_DEPRECATED ^ E_USER_DEPRECATED); + } + + protected function getPackageProviders($app): array + { + return [ + ServiceProvider::class, + ]; + } + + protected function getPackageAliases($app): array + { + return [ + 'Sentry' => Facade::class, + ]; + } + + public function testIsBound(): void + { + $this->assertTrue(app()->bound('sentry')); + $this->assertSame(app('sentry'), Facade::getFacadeRoot()); + $this->assertInstanceOf(HubInterface::class, app('sentry')); + } + + /** + * @depends testIsBound + */ + public function testEnvironment(): void + { + $this->assertEquals('testing', app('sentry')->getClient()->getOptions()->getEnvironment()); + } + + /** + * @depends testIsBound + */ + public function testDsnWasSetFromConfig(): void + { + /** @var \Sentry\Options $options */ + $options = app('sentry')->getClient()->getOptions(); + + $this->assertEquals('https://sentry.dev', $options->getDsn()->getScheme() . '://' . $options->getDsn()->getHost()); + $this->assertEquals(123, $options->getDsn()->getProjectId()); + $this->assertEquals('publickey', $options->getDsn()->getPublicKey()); + } + + /** + * @depends testIsBound + */ + public function testErrorTypesWasSetFromConfig(): void + { + $this->assertEquals( + E_ALL ^ E_DEPRECATED ^ E_USER_DEPRECATED, + app('sentry')->getClient()->getOptions()->getErrorTypes() + ); + } + + /** + * @depends testIsBound + */ + public function testArtisanCommandsAreRegistered(): void + { + $this->assertArrayHasKey('sentry:test', Artisan::all()); + $this->assertArrayHasKey('sentry:publish', Artisan::all()); + } +} diff --git a/packages/laravel/test/Sentry/ServiceProviderWithCustomAliasTest.php b/packages/laravel/test/Sentry/ServiceProviderWithCustomAliasTest.php new file mode 100644 index 000000000000..3a96906cd6c4 --- /dev/null +++ b/packages/laravel/test/Sentry/ServiceProviderWithCustomAliasTest.php @@ -0,0 +1,83 @@ +set('custom-sentry.dsn', 'http://publickey@sentry.dev/123'); + $app['config']->set('custom-sentry.error_types', E_ALL ^ E_DEPRECATED ^ E_USER_DEPRECATED); + } + + protected function getPackageProviders($app): array + { + return [ + CustomSentryServiceProvider::class, + ]; + } + + protected function getPackageAliases($app): array + { + return [ + 'CustomSentry' => CustomSentryFacade::class, + ]; + } + + public function testIsBound(): void + { + $this->assertTrue(app()->bound('custom-sentry')); + $this->assertInstanceOf(HubInterface::class, app('custom-sentry')); + $this->assertSame(app('custom-sentry'), CustomSentryFacade::getFacadeRoot()); + } + + /** + * @depends testIsBound + */ + public function testEnvironment(): void + { + $this->assertEquals('testing', app('custom-sentry')->getClient()->getOptions()->getEnvironment()); + } + + /** + * @depends testIsBound + */ + public function testDsnWasSetFromConfig(): void + { + /** @var \Sentry\Options $options */ + $options = app('custom-sentry')->getClient()->getOptions(); + + $this->assertEquals('http://sentry.dev', $options->getDsn()->getScheme() . '://' . $options->getDsn()->getHost()); + $this->assertEquals(123, $options->getDsn()->getProjectId()); + $this->assertEquals('publickey', $options->getDsn()->getPublicKey()); + } + + /** + * @depends testIsBound + */ + public function testErrorTypesWasSetFromConfig(): void + { + $this->assertEquals( + E_ALL ^ E_DEPRECATED ^ E_USER_DEPRECATED, + app('custom-sentry')->getClient()->getOptions()->getErrorTypes() + ); + } +} + +class CustomSentryServiceProvider extends ServiceProvider +{ + public static $abstract = 'custom-sentry'; +} + +class CustomSentryFacade extends Facade +{ + protected static function getFacadeAccessor(): string + { + return 'custom-sentry'; + } +} diff --git a/packages/laravel/test/Sentry/ServiceProviderWithEnvironmentFromConfigTest.php b/packages/laravel/test/Sentry/ServiceProviderWithEnvironmentFromConfigTest.php new file mode 100644 index 000000000000..2f4c96519d22 --- /dev/null +++ b/packages/laravel/test/Sentry/ServiceProviderWithEnvironmentFromConfigTest.php @@ -0,0 +1,37 @@ +assertEquals('testing', app()->environment()); + } + + public function testEmptySentryEnvironmentDefaultsToLaravelEnvironment(): void + { + $this->resetApplicationWithConfig([ + 'sentry.environment' => '', + ]); + + $this->assertEquals('testing', $this->getSentryClientFromContainer()->getOptions()->getEnvironment()); + + $this->resetApplicationWithConfig([ + 'sentry.environment' => null, + ]); + + $this->assertEquals('testing', $this->getSentryClientFromContainer()->getOptions()->getEnvironment()); + } + + public function testSentryEnvironmentDefaultGetsOverriddenByConfig(): void + { + $this->resetApplicationWithConfig([ + 'sentry.environment' => 'override_env', + ]); + + $this->assertEquals('override_env', $this->getSentryClientFromContainer()->getOptions()->getEnvironment()); + } +} diff --git a/packages/laravel/test/Sentry/ServiceProviderWithoutDsnTest.php b/packages/laravel/test/Sentry/ServiceProviderWithoutDsnTest.php new file mode 100644 index 000000000000..338a6668fe63 --- /dev/null +++ b/packages/laravel/test/Sentry/ServiceProviderWithoutDsnTest.php @@ -0,0 +1,52 @@ +set('sentry.dsn', null); + } + + protected function getPackageProviders($app): array + { + return [ + ServiceProvider::class, + ]; + } + + public function testIsBound(): void + { + $this->assertTrue(app()->bound('sentry')); + } + + /** + * @depends testIsBound + */ + public function testDsnIsNotSet(): void + { + $this->assertNull(app('sentry')->getClient()->getOptions()->getDsn()); + } + + /** + * @depends testIsBound + */ + public function testDidNotRegisterEvents(): void + { + $this->assertEquals(false, app('events')->hasListeners(RouteMatched::class)); + } + + /** + * @depends testIsBound + */ + public function testArtisanCommandsAreRegistered(): void + { + $this->assertArrayHasKey('sentry:test', Artisan::all()); + $this->assertArrayHasKey('sentry:publish', Artisan::all()); + } +} diff --git a/packages/laravel/test/Sentry/TestCase.php b/packages/laravel/test/Sentry/TestCase.php new file mode 100644 index 000000000000..f344ca733aba --- /dev/null +++ b/packages/laravel/test/Sentry/TestCase.php @@ -0,0 +1,232 @@ +resetApplicationWithConfig([ /* config */ ]);` helper method + ]; + + protected $defaultSetupConfig = []; + + /** @var array */ + protected static $lastSentryEvents = []; + + /** @param Application $app */ + protected function defineEnvironment($app): void + { + self::$lastSentryEvents = []; + + $this->setupGlobalEventProcessor(); + + tap($app['config'], function (Repository $config) { + // This key has no meaning, it's just a randomly generated one but it's required for the app to boot properly + $config->set('app.key', 'base64:JfXL2QpYC1+szaw+CdT6SHXG8zjdTkKM/ctPWoTWbXU='); + + $config->set('sentry.before_send', static function (Event $event, ?EventHint $hint) { + self::$lastSentryEvents[] = [$event, $hint]; + + return null; + }); + + $config->set('sentry.before_send_transaction', static function (Event $event, ?EventHint $hint) { + self::$lastSentryEvents[] = [$event, $hint]; + + return null; + }); + + if ($config->get('sentry_test.override_dsn') !== true) { + $config->set('sentry.dsn', 'https://publickey@sentry.dev/123'); + } + + foreach ($this->defaultSetupConfig as $key => $value) { + $config->set($key, $value); + } + + foreach ($this->setupConfig as $key => $value) { + $config->set($key, $value); + } + }); + + $app->extend(ExceptionHandler::class, function (ExceptionHandler $handler) { + return new TestCaseExceptionHandler($handler); + }); + } + + /** @param Application $app */ + protected function envWithoutDsnSet($app): void + { + $app['config']->set('sentry.dsn', null); + $app['config']->set('sentry_test.override_dsn', true); + } + + /** @param Application $app */ + protected function envSamplingAllTransactions($app): void + { + $app['config']->set('sentry.traces_sample_rate', 1.0); + } + + protected function getPackageProviders($app): array + { + return [ + ServiceProvider::class, + Tracing\ServiceProvider::class, + ]; + } + + protected function resetApplicationWithConfig(array $config): void + { + $this->setupConfig = $config; + + $this->refreshApplication(); + } + + protected function dispatchLaravelEvent($event, array $payload = []): void + { + $this->app['events']->dispatch($event, $payload); + } + + protected function getSentryHubFromContainer(): HubInterface + { + return $this->app->make('sentry'); + } + + protected function getSentryClientFromContainer(): ClientInterface + { + return $this->getSentryHubFromContainer()->getClient(); + } + + protected function getCurrentSentryScope(): Scope + { + $hub = $this->getSentryHubFromContainer(); + + $method = new ReflectionMethod($hub, 'getScope'); + $method->setAccessible(true); + + return $method->invoke($hub); + } + + /** @return array */ + protected function getCurrentSentryBreadcrumbs(): array + { + $scope = $this->getCurrentSentryScope(); + + $property = new ReflectionProperty($scope, 'breadcrumbs'); + $property->setAccessible(true); + + return $property->getValue($scope); + } + + protected function getLastSentryBreadcrumb(): ?Breadcrumb + { + $breadcrumbs = $this->getCurrentSentryBreadcrumbs(); + + if (empty($breadcrumbs)) { + return null; + } + + return end($breadcrumbs); + } + + protected function getLastSentryEvent(): ?Event + { + if (empty(self::$lastSentryEvents)) { + return null; + } + + return end(self::$lastSentryEvents)[0]; + } + + protected function getLastEventSentryHint(): ?EventHint + { + if (empty(self::$lastSentryEvents)) { + return null; + } + + return end(self::$lastSentryEvents)[1]; + } + + /** @return array */ + protected function getCapturedSentryEvents(): array + { + return self::$lastSentryEvents; + } + + protected function assertSentryEventCount(int $count): void + { + $this->assertCount($count, array_filter(self::$lastSentryEvents, static function (array $event) { + return $event[0]->getType() === EventType::event(); + })); + } + + protected function assertSentryCheckInCount(int $count): void + { + $this->assertCount($count, array_filter(self::$lastSentryEvents, static function (array $event) { + return $event[0]->getType() === EventType::checkIn(); + })); + } + + protected function assertSentryTransactionCount(int $count): void + { + $this->assertCount($count, array_filter(self::$lastSentryEvents, static function (array $event) { + return $event[0]->getType() === EventType::transaction(); + })); + } + + protected function startTransaction(): Transaction + { + $hub = $this->getSentryHubFromContainer(); + + $transaction = $hub->startTransaction(new TransactionContext); + $transaction->initSpanRecorder(); + $transaction->setSampled(true); + + $this->getCurrentSentryScope()->setSpan($transaction); + + return $transaction; + } + + private function setupGlobalEventProcessor(): void + { + if (self::$hasSetupGlobalEventProcessor) { + return; + } + + Scope::addGlobalEventProcessor(static function (Event $event, ?EventHint $hint) { + // Regular events and transactions are handled by the `before_send` and `before_send_transaction` callbacks + if (in_array($event->getType(), [EventType::event(), EventType::transaction()], true)) { + return $event; + } + + self::$lastSentryEvents[] = [$event, $hint]; + + return null; + }); + + self::$hasSetupGlobalEventProcessor = true; + } +} diff --git a/packages/laravel/test/Sentry/TestCaseExceptionHandler.php b/packages/laravel/test/Sentry/TestCaseExceptionHandler.php new file mode 100644 index 000000000000..ff285f4d650c --- /dev/null +++ b/packages/laravel/test/Sentry/TestCaseExceptionHandler.php @@ -0,0 +1,47 @@ +handler = $handler; + } + + public function report($e) + { + Integration::captureUnhandledException($e); + + $this->handler->report($e); + } + + public function shouldReport($e) + { + return $this->handler->shouldReport($e); + } + + public function render($request, $e) + { + return $this->handler->render($request, $e); + } + + public function renderForConsole($output, $e) + { + $this->handler->renderForConsole($output, $e); + } + + public function __call($name, $arguments) + { + return $this->handler->{$name}(...$arguments); + } +} diff --git a/packages/laravel/test/Sentry/Tracing/EventHandlerTest.php b/packages/laravel/test/Sentry/Tracing/EventHandlerTest.php new file mode 100644 index 000000000000..db4ceb311fc7 --- /dev/null +++ b/packages/laravel/test/Sentry/Tracing/EventHandlerTest.php @@ -0,0 +1,50 @@ +expectException(RuntimeException::class); + + $handler = new EventHandler([]); + + /** @noinspection PhpUndefinedMethodInspection */ + $handler->thisIsNotAHandlerAndShouldThrowAnException(); + } + + public function testAllMappedEventHandlersExist(): void + { + $this->tryAllEventHandlerMethods( + $this->getEventHandlerMapFromEventHandler() + ); + } + + private function tryAllEventHandlerMethods(array $methods): void + { + $handler = new EventHandler([]); + + $methods = array_map(static function ($method) { + return "{$method}Handler"; + }, array_unique(array_values($methods))); + + foreach ($methods as $handlerMethod) { + $this->assertTrue(method_exists($handler, $handlerMethod)); + } + } + + private function getEventHandlerMapFromEventHandler() + { + $class = new ReflectionClass(EventHandler::class); + + $attributes = $class->getStaticProperties(); + + return $attributes['eventHandlerMap']; + } +} diff --git a/packages/laravel/test/bootstrap.php b/packages/laravel/test/bootstrap.php new file mode 100644 index 000000000000..103015363502 --- /dev/null +++ b/packages/laravel/test/bootstrap.php @@ -0,0 +1,7 @@ + - - Sentry - -

- -# Official Sentry SDK for Next.js - -[![npm version](https://img.shields.io/npm/v/@sentry/nextjs.svg)](https://www.npmjs.com/package/@sentry/nextjs) -[![npm dm](https://img.shields.io/npm/dm/@sentry/nextjs.svg)](https://www.npmjs.com/package/@sentry/nextjs) -[![npm dt](https://img.shields.io/npm/dt/@sentry/nextjs.svg)](https://www.npmjs.com/package/@sentry/nextjs) - -> See the [Official Sentry Next.js SDK Docs](https://docs.sentry.io/platforms/javascript/guides/nextjs/) to get started. - -## Compatibility - -Currently, the minimum supported version of Next.js is `13.2.0`. - -## Installation - -To get started installing the SDK, use the Sentry Next.js Wizard by running the following command in your terminal or -read the [Getting Started Docs](https://docs.sentry.io/platforms/javascript/guides/nextjs/): - -```sh -npx @sentry/wizard@latest -i nextjs -``` - -The wizard will prompt you to log in to Sentry. After the wizard setup is completed, the SDK will automatically capture -unhandled errors, and monitor performance. - -## Custom Usage - -To set context information or to send manual events, you can use `@sentry/nextjs` as follows: - -```ts -import * as Sentry from '@sentry/nextjs'; - -// Set user information, as well as tags and further extras -Sentry.setTag('user_mode', 'admin'); -Sentry.setUser({ id: '4711' }); -Sentry.setContext('application_area', { location: 'checkout' }); - -// Add a breadcrumb for future events -Sentry.addBreadcrumb({ - message: '"Add to cart" clicked', - // ... -}); - -// Capture exceptions or messages -Sentry.captureException(new Error('Oh no.')); -Sentry.captureMessage('Hello, world!'); -``` - -## Links - -- [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/nextjs/) -- [Sentry.io](https://sentry.io/?utm_source=github&utm_medium=npm_nextjs) -- [Sentry Discord Server](https://discord.gg/Ww9hbqr) -- [Stack Overflow](https://stackoverflow.com/questions/tagged/sentry) diff --git a/packages/nextjs/jest.config.js b/packages/nextjs/jest.config.js deleted file mode 100644 index fd23311e1656..000000000000 --- a/packages/nextjs/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -const baseConfig = require('../../jest/jest.config.js'); - -module.exports = { - ...baseConfig, -}; diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs deleted file mode 100644 index afe41659238f..000000000000 --- a/packages/nextjs/rollup.npm.config.mjs +++ /dev/null @@ -1,76 +0,0 @@ -import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; - -export default [ - ...makeNPMConfigVariants( - makeBaseNPMConfig({ - // We need to include `instrumentServer.ts` separately because it's only conditionally required, and so rollup - // doesn't automatically include it when calculating the module dependency tree. - entrypoints: [ - 'src/index.server.ts', - 'src/index.client.ts', - 'src/client/index.ts', - 'src/server/index.ts', - 'src/edge/index.ts', - 'src/config/index.ts', - ], - - // prevent this internal nextjs code from ending up in our built package (this doesn't happen automatially because - // the name doesn't match an SDK dependency) - packageSpecificConfig: { - external: ['next/router', 'next/constants', 'next/headers', 'stacktrace-parser'], - }, - }), - ), - ...makeNPMConfigVariants( - makeBaseNPMConfig({ - entrypoints: [ - 'src/config/templates/apiWrapperTemplate.ts', - 'src/config/templates/middlewareWrapperTemplate.ts', - 'src/config/templates/pageWrapperTemplate.ts', - 'src/config/templates/requestAsyncStorageShim.ts', - 'src/config/templates/sentryInitWrapperTemplate.ts', - 'src/config/templates/serverComponentWrapperTemplate.ts', - 'src/config/templates/routeHandlerWrapperTemplate.ts', - ], - - packageSpecificConfig: { - output: { - // Preserve the original file structure (i.e., so that everything is still relative to `src`) - entryFileNames: 'config/templates/[name].js', - - // this is going to be add-on code, so it doesn't need the trappings of a full module (and in fact actively - // shouldn't have them, lest they muck with the module to which we're adding it) - sourcemap: false, - esModule: false, - - // make it so Rollup calms down about the fact that we're combining default and named exports - exports: 'named', - }, - external: [ - '@sentry/nextjs', - 'next/dist/client/components/request-async-storage', - '__SENTRY_CONFIG_IMPORT_PATH__', - '__SENTRY_WRAPPING_TARGET_FILE__', - '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__', - ], - }, - }), - ), - ...makeNPMConfigVariants( - makeBaseNPMConfig({ - entrypoints: ['src/config/loaders/index.ts'], - - packageSpecificConfig: { - output: { - // Preserve the original file structure (i.e., so that everything is still relative to `src`) - entryFileNames: 'config/loaders/[name].js', - - // make it so Rollup calms down about the fact that we're combining default and named exports - exports: 'named', - }, - external: ['@rollup/plugin-commonjs', 'rollup'], - }, - }), - ), - ...makeOtelLoaders('./build', 'sentry-node'), -]; diff --git a/packages/nextjs/scripts/buildRollup.ts b/packages/nextjs/scripts/buildRollup.ts deleted file mode 100644 index d273146b872d..000000000000 --- a/packages/nextjs/scripts/buildRollup.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as childProcess from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -/** - * Run the given shell command, piping the shell process's `stdin`, `stdout`, and `stderr` to that of the current - * process. Returns contents of `stdout`. - */ -function run(cmd: string, options?: childProcess.ExecSyncOptions): string | Buffer { - return childProcess.execSync(cmd, { stdio: 'inherit', ...options }); -} - -run('yarn rollup -c rollup.npm.config.mjs'); - -// Regardless of whether nextjs is using the CJS or ESM version of our SDK, we want the code from our templates to be in -// ESM (since we'll be adding it onto page files which are themselves written in ESM), so copy the ESM versions of the -// templates over into the CJS build directory. (Building only the ESM version and sticking it in both locations is -// something which in theory Rollup could do, but it would mean refactoring our Rollup helper functions, which isn't -// worth it just for this.) -const cjsTemplateDir = 'build/cjs/config/templates/'; -const esmTemplateDir = 'build/esm/config/templates/'; -fs.readdirSync(esmTemplateDir).forEach(templateFile => - fs.copyFileSync(path.join(esmTemplateDir, templateFile), path.join(cjsTemplateDir, templateFile)), -); diff --git a/packages/nextjs/src/client/browserTracingIntegration.ts b/packages/nextjs/src/client/browserTracingIntegration.ts deleted file mode 100644 index c50dbbf1686e..000000000000 --- a/packages/nextjs/src/client/browserTracingIntegration.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/react'; -import type { Integration } from '@sentry/types'; -import { nextRouterInstrumentNavigation, nextRouterInstrumentPageLoad } from './routing/nextRoutingInstrumentation'; - -/** - * A custom browser tracing integration for Next.js. - */ -export function browserTracingIntegration( - options: Parameters[0] = {}, -): Integration { - const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ - ...options, - instrumentNavigation: false, - instrumentPageLoad: false, - }); - - const { instrumentPageLoad = true, instrumentNavigation = true } = options; - - return { - ...browserTracingIntegrationInstance, - afterAllSetup(client) { - // We need to run the navigation span instrumentation before the `afterAllSetup` hook on the normal browser - // tracing integration because we need to ensure the order of execution is as follows: - // Instrumentation to start span on RSC fetch request runs -> Instrumentation to put tracing headers from active span on fetch runs - // If it were the other way around, the RSC fetch request would not receive the tracing headers from the navigation transaction. - if (instrumentNavigation) { - nextRouterInstrumentNavigation(client); - } - - browserTracingIntegrationInstance.afterAllSetup(client); - - if (instrumentPageLoad) { - nextRouterInstrumentPageLoad(client); - } - }, - }; -} diff --git a/packages/nextjs/src/client/clientNormalizationIntegration.ts b/packages/nextjs/src/client/clientNormalizationIntegration.ts deleted file mode 100644 index 06f010c980c9..000000000000 --- a/packages/nextjs/src/client/clientNormalizationIntegration.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { rewriteFramesIntegration } from '@sentry/browser'; -import { defineIntegration } from '@sentry/core'; - -export const nextjsClientStackFrameNormalizationIntegration = defineIntegration( - ({ assetPrefixPath }: { assetPrefixPath: string }) => { - const rewriteFramesInstance = rewriteFramesIntegration({ - // Turn `//_next/static/...` into `app:///_next/static/...` - iteratee: frame => { - try { - const { origin } = new URL(frame.filename as string); - frame.filename = frame.filename?.replace(origin, 'app://').replace(assetPrefixPath, ''); - } catch (err) { - // Filename wasn't a properly formed URL, so there's nothing we can do - } - - // We need to URI-decode the filename because Next.js has wildcard routes like "/users/[id].js" which show up as "/users/%5id%5.js" in Error stacktraces. - // The corresponding sources that Next.js generates have proper brackets so we also need proper brackets in the frame so that source map resolving works. - if (frame.filename && frame.filename.startsWith('app:///_next')) { - frame.filename = decodeURI(frame.filename); - } - - if ( - frame.filename && - frame.filename.match( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, - ) - ) { - // We don't care about these frames. It's Next.js internal code. - frame.in_app = false; - } - - return frame; - }, - }); - - return { - ...rewriteFramesInstance, - name: 'NextjsClientStackFrameNormalization', - }; - }, -); diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts deleted file mode 100644 index a68734a10398..000000000000 --- a/packages/nextjs/src/client/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { addEventProcessor, applySdkMetadata } from '@sentry/core'; -import type { BrowserOptions } from '@sentry/react'; -import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; -import type { Client, EventProcessor, Integration } from '@sentry/types'; -import { GLOBAL_OBJ } from '@sentry/utils'; - -import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; -import { getVercelEnv } from '../common/getVercelEnv'; -import { browserTracingIntegration } from './browserTracingIntegration'; -import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; -import { applyTunnelRouteOption } from './tunnelRoute'; - -export * from '@sentry/react'; - -export { captureUnderscoreErrorException } from '../common/_error'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __rewriteFramesAssetPrefixPath__: string; -}; - -// Treeshakable guard to remove all code related to tracing -declare const __SENTRY_TRACING__: boolean; - -/** Inits the Sentry NextJS SDK on the browser with the React SDK. */ -export function init(options: BrowserOptions): Client | undefined { - const opts = { - environment: getVercelEnv(true) || process.env.NODE_ENV, - defaultIntegrations: getDefaultIntegrations(options), - ...options, - } satisfies BrowserOptions; - - applyTunnelRouteOption(opts); - applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); - - const client = reactInit(opts); - - const filterTransactions: EventProcessor = event => - event.type === 'transaction' && event.transaction === '/404' ? null : event; - filterTransactions.id = 'NextClient404Filter'; - addEventProcessor(filterTransactions); - - if (process.env.NODE_ENV === 'development') { - addEventProcessor(devErrorSymbolicationEventProcessor); - } - - return client; -} - -function getDefaultIntegrations(options: BrowserOptions): Integration[] { - const customDefaultIntegrations = getReactDefaultIntegrations(options); - // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", - // in which case everything inside will get tree-shaken away - if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { - customDefaultIntegrations.push(browserTracingIntegration()); - } - - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const assetPrefixPath = globalWithInjectedValues.__rewriteFramesAssetPrefixPath__ || ''; - customDefaultIntegrations.push(nextjsClientStackFrameNormalizationIntegration({ assetPrefixPath })); - - return customDefaultIntegrations; -} - -/** - * Just a passthrough in case this is imported from the client. - */ -export function withSentryConfig(exportedUserNextConfig: T): T { - return exportedUserNextConfig; -} - -export { browserTracingIntegration } from './browserTracingIntegration'; - -export * from '../common'; diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts deleted file mode 100644 index 25c1496d25b4..000000000000 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, -} from '@sentry/core'; -import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '@sentry/react'; -import type { Client } from '@sentry/types'; -import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils'; - -/** Instruments the Next.js app router for pageloads. */ -export function appRouterInstrumentPageLoad(client: Client): void { - startBrowserTracingPageLoadSpan(client, { - name: WINDOW.location.pathname, - // pageload should always start at timeOrigin (and needs to be in s, not ms) - startTime: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - }, - }); -} - -/** Instruments the Next.js app router for navigation. */ -export function appRouterInstrumentNavigation(client: Client): void { - addFetchInstrumentationHandler(handlerData => { - // The instrumentation handler is invoked twice - once for starting a request and once when the req finishes - // We can use the existence of the end-timestamp to filter out "finishing"-events. - if (handlerData.endTimestamp !== undefined) { - return; - } - - // Only GET requests can be navigating RSC requests - if (handlerData.fetchData.method !== 'GET') { - return; - } - - const parsedNavigatingRscFetchArgs = parseNavigatingRscFetchArgs(handlerData.args); - - if (parsedNavigatingRscFetchArgs === null) { - return; - } - - const newPathname = parsedNavigatingRscFetchArgs.targetPathname; - - startBrowserTracingNavigationSpan(client, { - name: newPathname, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - }, - }); - }); -} - -function parseNavigatingRscFetchArgs(fetchArgs: unknown[]): null | { - targetPathname: string; -} { - // Make sure the first arg is a URL object - if (!fetchArgs[0] || typeof fetchArgs[0] !== 'object' || (fetchArgs[0] as URL).searchParams === undefined) { - return null; - } - - // Make sure the second argument is some kind of fetch config obj that contains headers - if (!fetchArgs[1] || typeof fetchArgs[1] !== 'object' || !('headers' in fetchArgs[1])) { - return null; - } - - try { - const url = fetchArgs[0] as URL; - const headers = fetchArgs[1].headers as Record; - - // Not an RSC request - if (headers['RSC'] !== '1') { - return null; - } - - // Prefetch requests are not navigating RSC requests - if (headers['Next-Router-Prefetch'] === '1') { - return null; - } - - return { - targetPathname: url.pathname, - }; - } catch { - return null; - } -} diff --git a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts deleted file mode 100644 index a25c765e0143..000000000000 --- a/packages/nextjs/src/client/routing/nextRoutingInstrumentation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { WINDOW } from '@sentry/react'; -import type { Client } from '@sentry/types'; - -import { appRouterInstrumentNavigation, appRouterInstrumentPageLoad } from './appRouterRoutingInstrumentation'; -import { pagesRouterInstrumentNavigation, pagesRouterInstrumentPageLoad } from './pagesRouterRoutingInstrumentation'; - -/** - * Instruments the Next.js Client Router for page loads. - */ -export function nextRouterInstrumentPageLoad(client: Client): void { - const isAppRouter = !WINDOW.document.getElementById('__NEXT_DATA__'); - if (isAppRouter) { - appRouterInstrumentPageLoad(client); - } else { - pagesRouterInstrumentPageLoad(client); - } -} - -/** - * Instruments the Next.js Client Router for navigation. - */ -export function nextRouterInstrumentNavigation(client: Client): void { - const isAppRouter = !WINDOW.document.getElementById('__NEXT_DATA__'); - if (isAppRouter) { - appRouterInstrumentNavigation(client); - } else { - pagesRouterInstrumentNavigation(client); - } -} diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts deleted file mode 100644 index f6906a566050..000000000000 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { ParsedUrlQuery } from 'querystring'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, -} from '@sentry/core'; -import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '@sentry/react'; -import type { Client, TransactionSource } from '@sentry/types'; -import { browserPerformanceTimeOrigin, logger, stripUrlQueryAndFragment } from '@sentry/utils'; - -import type { NEXT_DATA } from 'next/dist/shared/lib/utils'; -import RouterImport from 'next/router'; - -// next/router v10 is CJS -// -// For ESM/CJS interoperability 'reasons', depending on how this file is loaded, Router might be on the default export -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -const Router: typeof RouterImport = RouterImport.events ? RouterImport : (RouterImport as any).default; - -import { DEBUG_BUILD } from '../../common/debug-build'; - -const globalObject = WINDOW as typeof WINDOW & { - __BUILD_MANIFEST?: { - sortedPages?: string[]; - }; -}; - -/** - * Describes data located in the __NEXT_DATA__ script tag. This tag is present on every page of a Next.js app. - */ -interface SentryEnhancedNextData extends NEXT_DATA { - props: { - pageProps?: { - _sentryTraceData?: string; // trace parent info, if injected by a data-fetcher - _sentryBaggage?: string; // baggage, if injected by a data-fetcher - // These two values are only injected by `getStaticProps` in a very special case with the following conditions: - // 1. The page's `getStaticPaths` method must have returned `fallback: 'blocking'`. - // 2. The requested page must be a "miss" in terms of "Incremental Static Regeneration", meaning the requested page has not been generated before. - // In this case, a page is requested and only served when `getStaticProps` is done. There is not even a fallback page or similar. - }; - }; -} - -interface NextDataTagInfo { - route?: string; - params?: ParsedUrlQuery; - sentryTrace?: string; - baggage?: string; -} - -/** - * Every Next.js page (static and dynamic ones) comes with a script tag with the id "__NEXT_DATA__". This script tag - * contains a JSON object with data that was either generated at build time for static pages (`getStaticProps`), or at - * runtime with data fetchers like `getServerSideProps.`. - * - * We can use this information to: - * - Always get the parameterized route we're in when loading a page. - * - Send trace information (trace-id, baggage) from the server to the client. - * - * This function extracts this information. - */ -function extractNextDataTagInformation(): NextDataTagInfo { - let nextData: SentryEnhancedNextData | undefined; - // Let's be on the safe side and actually check first if there is really a __NEXT_DATA__ script tag on the page. - // Theoretically this should always be the case though. - const nextDataTag = globalObject.document.getElementById('__NEXT_DATA__'); - if (nextDataTag && nextDataTag.innerHTML) { - try { - nextData = JSON.parse(nextDataTag.innerHTML); - } catch (e) { - DEBUG_BUILD && logger.warn('Could not extract __NEXT_DATA__'); - } - } - - if (!nextData) { - return {}; - } - - const nextDataTagInfo: NextDataTagInfo = {}; - - const { page, query, props } = nextData; - - // `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching - // function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the - // parent transaction - // TODO: Actually this is a problem (even though it is not that big), because the DSC and the transaction payload will contain - // a different transaction name. Maybe we can fix this. Idea: Also send transaction name via pageProps when available. - nextDataTagInfo.route = page; - nextDataTagInfo.params = query; - - if (props && props.pageProps) { - nextDataTagInfo.sentryTrace = props.pageProps._sentryTraceData; - nextDataTagInfo.baggage = props.pageProps._sentryBaggage; - } - - return nextDataTagInfo; -} - -/** - * Instruments the Next.js pages router for pageloads. - * Only supported for client side routing. Works for Next >= 10. - * - * Leverages the SingletonRouter from the `next/router` to - * generate pageload/navigation transactions and parameterize - * transaction names. - */ -export function pagesRouterInstrumentPageLoad(client: Client): void { - const { route, params, sentryTrace, baggage } = extractNextDataTagInformation(); - const name = route || globalObject.location.pathname; - - startBrowserTracingPageLoadSpan( - client, - { - name, - // pageload should always start at timeOrigin (and needs to be in s, not ms) - startTime: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.nextjs.pages_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: route ? 'route' : 'url', - ...(params && client.getOptions().sendDefaultPii && { ...params }), - }, - }, - { sentryTrace, baggage }, - ); -} - -/** - * Instruments the Next.js pages router for navigation. - * Only supported for client side routing. Works for Next >= 10. - * - * Leverages the SingletonRouter from the `next/router` to - * generate pageload/navigation transactions and parameterize - * transaction names. - */ -export function pagesRouterInstrumentNavigation(client: Client): void { - Router.events.on('routeChangeStart', (navigationTarget: string) => { - const strippedNavigationTarget = stripUrlQueryAndFragment(navigationTarget); - const matchedRoute = getNextRouteFromPathname(strippedNavigationTarget); - - let newLocation: string; - let spanSource: TransactionSource; - - if (matchedRoute) { - newLocation = matchedRoute; - spanSource = 'route'; - } else { - newLocation = strippedNavigationTarget; - spanSource = 'url'; - } - - startBrowserTracingNavigationSpan(client, { - name: newLocation, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.pages_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: spanSource, - }, - }); - }); -} - -function getNextRouteFromPathname(pathname: string): string | undefined { - const pageRoutes = (globalObject.__BUILD_MANIFEST || {}).sortedPages; - - // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here - if (!pageRoutes) { - return; - } - - return pageRoutes.find(route => { - const routeRegExp = convertNextRouteToRegExp(route); - return pathname.match(routeRegExp); - }); -} - -/** - * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). - * - * In general this involves replacing any instances of square brackets in a route with a wildcard: - * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ - * - * Some additional edgecases need to be considered: - * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or - * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". - * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). - * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). - * - * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` - */ -function convertNextRouteToRegExp(route: string): RegExp { - // We can assume a route is at least "/". - const routeParts = route.split('/'); - - let optionalCatchallWildcardRegex = ''; - if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { - // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing - // slash that would come before it if we didn't pop it. - routeParts.pop(); - optionalCatchallWildcardRegex = '(?:/(.+?))?'; - } - - const rejoinedRouteParts = routeParts - .map( - routePart => - routePart - .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard - .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards - ) - .join('/'); - - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input - return new RegExp( - `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end - ); -} diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts deleted file mode 100644 index 3c93b93e41f2..000000000000 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { BrowserOptions } from '@sentry/react'; -import { GLOBAL_OBJ, dsnFromString, logger } from '@sentry/utils'; - -import { DEBUG_BUILD } from '../common/debug-build'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __sentryRewritesTunnelPath__?: string; -}; - -/** - * Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option. - */ -export function applyTunnelRouteOption(options: BrowserOptions): void { - const tunnelRouteOption = globalWithInjectedValues.__sentryRewritesTunnelPath__; - if (tunnelRouteOption && options.dsn) { - const dsnComponents = dsnFromString(options.dsn); - if (!dsnComponents) { - return; - } - const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/); - if (sentrySaasDsnMatch) { - const orgId = sentrySaasDsnMatch[1]; - const regionCode = sentrySaasDsnMatch[2]; - let tunnelPath = `${tunnelRouteOption}?o=${orgId}&p=${dsnComponents.projectId}`; - if (regionCode) { - tunnelPath += `&r=${regionCode}`; - } - options.tunnel = tunnelPath; - DEBUG_BUILD && logger.info(`Tunneling events to "${tunnelPath}"`); - } else { - DEBUG_BUILD && logger.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); - } - } -} diff --git a/packages/nextjs/src/common/_error.ts b/packages/nextjs/src/common/_error.ts deleted file mode 100644 index f3a198919a72..000000000000 --- a/packages/nextjs/src/common/_error.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { captureException, withScope } from '@sentry/core'; -import type { NextPageContext } from 'next'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { vercelWaitUntil } from './utils/vercelWaitUntil'; - -type ContextOrProps = { - req?: NextPageContext['req']; - res?: NextPageContext['res']; - err?: NextPageContext['err'] | string; - pathname?: string; - statusCode?: number; -}; - -/** - * Capture the exception passed by nextjs to the `_error` page, adding context data as appropriate. - * - * @param contextOrProps The data passed to either `getInitialProps` or `render` by nextjs - */ -export async function captureUnderscoreErrorException(contextOrProps: ContextOrProps): Promise { - const { req, res, err } = contextOrProps; - - // 404s (and other 400-y friends) can trigger `_error`, but we don't want to send them to Sentry - const statusCode = (res && res.statusCode) || contextOrProps.statusCode; - if (statusCode && statusCode < 500) { - return Promise.resolve(); - } - - // In previous versions of the suggested `_error.js` page in which this function is meant to be used, there was a - // workaround for https://github.com/vercel/next.js/issues/8592 which involved an extra call to this function, in the - // custom error component's `render` method, just in case it hadn't been called by `getInitialProps`. Now that that - // issue has been fixed, the second call is unnecessary, but since it lives in user code rather than our code, users - // have to be the ones to get rid of it, and guaraneteedly, not all of them will. So, rather than capture the error - // twice, we just bail if we sense we're in that now-extraneous second call. (We can tell which function we're in - // because Nextjs passes `pathname` to `getInitialProps` but not to `render`.) - if (!contextOrProps.pathname) { - return Promise.resolve(); - } - - withScope(scope => { - if (req) { - scope.setSDKProcessingMetadata({ request: req }); - } - - // If third-party libraries (or users themselves) throw something falsy, we want to capture it as a message (which - // is what passing a string to `captureException` will wind up doing) - captureException(err || `_error.js called with falsy error (${err})`, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: '_error.getInitialProps', - }, - }, - }); - }); - - vercelWaitUntil(flushSafelyWithTimeout()); -} diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts deleted file mode 100644 index 1556076619a0..000000000000 --- a/packages/nextjs/src/common/captureRequestError.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { captureException, withScope } from '@sentry/core'; - -type RequestInfo = { - path: string; - method: string; - headers: Record; -}; - -type ErrorContext = { - routerKind: string; // 'Pages Router' | 'App Router' - routePath: string; - routeType: string; // 'render' | 'route' | 'middleware' -}; - -/** - * Reports errors passed to the the Next.js `onRequestError` instrumentation hook. - */ -export function captureRequestError(error: unknown, request: RequestInfo, errorContext: ErrorContext): void { - withScope(scope => { - scope.setSDKProcessingMetadata({ - request: { - headers: request.headers, - method: request.method, - }, - }); - - scope.setContext('nextjs', { - request_path: request.path, - router_kind: errorContext.routerKind, - router_path: errorContext.routePath, - route_type: errorContext.routeType, - }); - - scope.setTransactionName(errorContext.routePath); - - captureException(error, { - mechanism: { - handled: false, - }, - }); - }); -} - -/** - * Reports errors passed to the the Next.js `onRequestError` instrumentation hook. - * - * @deprecated Use `captureRequestError` instead. - */ -// TODO(v9): Remove this export -export const experimental_captureRequestError = captureRequestError; diff --git a/packages/nextjs/src/common/debug-build.ts b/packages/nextjs/src/common/debug-build.ts deleted file mode 100644 index 60aa50940582..000000000000 --- a/packages/nextjs/src/common/debug-build.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const __DEBUG_BUILD__: boolean; - -/** - * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. - * - * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. - */ -export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts deleted file mode 100644 index 6c37859a851d..000000000000 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { suppressTracing } from '@sentry/core'; -import type { Event, EventHint } from '@sentry/types'; -import { GLOBAL_OBJ } from '@sentry/utils'; -import type { StackFrame } from 'stacktrace-parser'; -import * as stackTraceParser from 'stacktrace-parser'; - -type OriginalStackFrameResponse = { - originalStackFrame: StackFrame; - originalCodeFrame: string | null; - sourcePackage?: string; -}; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __sentryBasePath?: string; -}; - -async function resolveStackFrame( - frame: StackFrame, - error: Error, -): Promise<{ originalCodeFrame: string | null; originalStackFrame: StackFrame | null } | null> { - try { - if (!(frame.file?.startsWith('webpack-internal:') || frame.file?.startsWith('file:'))) { - return null; - } - - const params = new URLSearchParams(); - params.append('isServer', String(false)); // doesn't matter since it is overwritten by isAppDirectory - params.append('isEdgeServer', String(false)); // doesn't matter since it is overwritten by isAppDirectory - params.append('isAppDirectory', String(true)); // will force server to do more thorough checking - params.append('errorMessage', error.toString()); - Object.keys(frame).forEach(key => { - params.append(key, (frame[key as keyof typeof frame] ?? '').toString()); - }); - - let basePath = globalWithInjectedValues.__sentryBasePath ?? ''; - - // Prefix the basepath with a slash if it doesn't have one - if (basePath !== '' && !basePath.match(/^\//)) { - basePath = `/${basePath}`; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - const res = await suppressTracing(() => - fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port - }${basePath}/__nextjs_original-stack-frame?${params.toString()}`, - { - signal: controller.signal, - }, - ).finally(() => { - clearTimeout(timer); - }), - ); - - if (!res.ok || res.status === 204) { - return null; - } - - const body: OriginalStackFrameResponse = await res.json(); - - return { - originalCodeFrame: body.originalCodeFrame, - originalStackFrame: body.originalStackFrame, - }; - } catch (e) { - return null; - } -} - -function parseOriginalCodeFrame(codeFrame: string): { - contextLine: string | undefined; - preContextLines: string[]; - postContextLines: string[]; -} { - const preProcessedLines = codeFrame - // Remove ASCII control characters that are used for syntax highlighting - .replace( - // eslint-disable-next-line no-control-regex - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, // https://stackoverflow.com/a/29497680 - '', - ) - .split('\n') - // Remove line that is supposed to indicate where the error happened - .filter(line => !line.match(/^\s*\|/)) - // Find the error line - .map(line => ({ - line, - isErrorLine: !!line.match(/^>/), - })) - // Remove the leading part that is just for prettier output - .map(lineObj => ({ - ...lineObj, - line: lineObj.line.replace(/^.*\|/, ''), - })); - - const preContextLines = []; - let contextLine: string | undefined = undefined; - const postContextLines = []; - - let reachedContextLine = false; - - for (const preProcessedLine of preProcessedLines) { - if (preProcessedLine.isErrorLine) { - contextLine = preProcessedLine.line; - reachedContextLine = true; - } else if (reachedContextLine) { - postContextLines.push(preProcessedLine.line); - } else { - preContextLines.push(preProcessedLine.line); - } - } - - return { - contextLine, - preContextLines, - postContextLines, - }; -} - -/** - * Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces - * in the dev overlay. - */ -export async function devErrorSymbolicationEventProcessor(event: Event, hint: EventHint): Promise { - // Filter out spans for requests resolving source maps for stack frames in dev mode - if (event.type === 'transaction') { - event.spans = event.spans?.filter(span => { - const httpUrlAttribute: unknown = span.data?.['http.url']; - if (typeof httpUrlAttribute === 'string') { - return !httpUrlAttribute.includes('__nextjs_original-stack-frame'); - } - - return true; - }); - } - - // Due to changes across Next.js versions, there are a million things that can go wrong here so we just try-catch the // entire event processor.Symbolicated stack traces are just a nice to have. - try { - if (hint.originalException && hint.originalException instanceof Error && hint.originalException.stack) { - const frames = stackTraceParser.parse(hint.originalException.stack); - - const resolvedFrames = await Promise.all( - frames.map(frame => resolveStackFrame(frame, hint.originalException as Error)), - ); - - if (event.exception?.values?.[0]?.stacktrace?.frames) { - event.exception.values[0].stacktrace.frames = event.exception.values[0].stacktrace.frames.map( - (frame, i, frames) => { - const resolvedFrame = resolvedFrames[frames.length - 1 - i]; - if (!resolvedFrame || !resolvedFrame.originalStackFrame || !resolvedFrame.originalCodeFrame) { - return { - ...frame, - platform: frame.filename?.startsWith('node:internal') ? 'nodejs' : undefined, // simple hack that will prevent a source mapping error from showing up - in_app: false, - }; - } - - const { contextLine, preContextLines, postContextLines } = parseOriginalCodeFrame( - resolvedFrame.originalCodeFrame, - ); - - return { - ...frame, - pre_context: preContextLines, - context_line: contextLine, - post_context: postContextLines, - function: resolvedFrame.originalStackFrame.methodName, - filename: resolvedFrame.originalStackFrame.file || undefined, - lineno: resolvedFrame.originalStackFrame.lineNumber || undefined, - colno: resolvedFrame.originalStackFrame.column || undefined, - }; - }, - ); - } - } - } catch (e) { - return event; - } - - return event; -} diff --git a/packages/nextjs/src/common/getVercelEnv.ts b/packages/nextjs/src/common/getVercelEnv.ts deleted file mode 100644 index 98755c91409d..000000000000 --- a/packages/nextjs/src/common/getVercelEnv.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Returns an environment setting value determined by Vercel's `VERCEL_ENV` environment variable. - * - * @param isClient Flag to indicate whether to use the `NEXT_PUBLIC_` prefixed version of the environment variable. - */ -export function getVercelEnv(isClient: boolean): string | undefined { - const vercelEnvVar = isClient ? process.env.NEXT_PUBLIC_VERCEL_ENV : process.env.VERCEL_ENV; - return vercelEnvVar ? `vercel-${vercelEnvVar}` : undefined; -} diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts deleted file mode 100644 index 354113637a30..000000000000 --- a/packages/nextjs/src/common/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { wrapGetStaticPropsWithSentry } from './wrapGetStaticPropsWithSentry'; -export { wrapGetInitialPropsWithSentry } from './wrapGetInitialPropsWithSentry'; -export { wrapAppGetInitialPropsWithSentry } from './wrapAppGetInitialPropsWithSentry'; -export { wrapDocumentGetInitialPropsWithSentry } from './wrapDocumentGetInitialPropsWithSentry'; -export { wrapErrorGetInitialPropsWithSentry } from './wrapErrorGetInitialPropsWithSentry'; -export { wrapGetServerSidePropsWithSentry } from './wrapGetServerSidePropsWithSentry'; -export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry'; -export { wrapRouteHandlerWithSentry } from './wrapRouteHandlerWithSentry'; -export { wrapApiHandlerWithSentryVercelCrons } from './wrapApiHandlerWithSentryVercelCrons'; -export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry'; -export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry'; -export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; -export { withServerActionInstrumentation } from './withServerActionInstrumentation'; -// eslint-disable-next-line deprecation/deprecation -export { experimental_captureRequestError, captureRequestError } from './captureRequestError'; diff --git a/packages/nextjs/src/common/nextNavigationErrorUtils.ts b/packages/nextjs/src/common/nextNavigationErrorUtils.ts deleted file mode 100644 index d4a67791525f..000000000000 --- a/packages/nextjs/src/common/nextNavigationErrorUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isError } from '@sentry/utils'; - -/** - * Determines whether input is a Next.js not-found error. - * https://beta.nextjs.org/docs/api-reference/notfound#notfound - */ -export function isNotFoundNavigationError(subject: unknown): boolean { - return isError(subject) && (subject as Error & { digest?: unknown }).digest === 'NEXT_NOT_FOUND'; -} - -/** - * Determines whether input is a Next.js redirect error. - * https://beta.nextjs.org/docs/api-reference/redirect#redirect - */ -export function isRedirectNavigationError(subject: unknown): boolean { - return ( - isError(subject) && - typeof (subject as Error & { digest?: unknown }).digest === 'string' && - (subject as Error & { digest: string }).digest.startsWith('NEXT_REDIRECT;') // a redirect digest looks like "NEXT_REDIRECT;[redirect path]" - ); -} diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts deleted file mode 100644 index 05d89d3e3159..000000000000 --- a/packages/nextjs/src/common/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { SentrySpan } from '@sentry/core'; -import type { WebFetchHeaders, WrappedFunction } from '@sentry/types'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import type { RequestAsyncStorage } from '../config/templates/requestAsyncStorageShim'; - -export type ServerComponentContext = { - componentRoute: string; - componentType: 'Page' | 'Layout' | 'Head' | 'Not-found' | 'Loading' | 'Unknown'; - headers?: WebFetchHeaders; -}; - -export type GenerationFunctionContext = { - requestAsyncStorage?: RequestAsyncStorage; - componentRoute: string; - componentType: string; - generationFunctionIdentifier: string; -}; - -export interface RouteHandlerContext { - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; - parameterizedRoute: string; - headers?: WebFetchHeaders; -} - -export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined; - -// The `NextApiHandler` and `WrappedNextApiHandler` types are the same as the official `NextApiHandler` type, except: -// -// a) The wrapped version returns only promises, because wrapped handlers are always async. -// -// b) Instead of having a return types based on `void` (Next < 12.1.6) or `unknown` (Next 12.1.6+), both the wrapped and -// unwrapped versions of the type have both. This doesn't matter to users, because they exist solely on one side of that -// version divide or the other. For us, though, it's entirely possible to have one version of Next installed in our -// local repo (as a dev dependency) and have another Next version installed in a test app which also has the local SDK -// linked in. -// -// In that case, if those two versions are on either side of the 12.1.6 divide, importing the official `NextApiHandler` -// type here would break the test app's build, because it would set up a situation in which the linked SDK's -// `withSentry` would refer to one version of the type (from the local repo's `node_modules`) while any typed handler in -// the test app would refer to the other version of the type (from the test app's `node_modules`). By using a custom -// version of the type compatible with both the old and new official versions, we can use any Next version we want in a -// test app without worrying about type errors. -// -// c) These have internal SDK flags which the official Next types obviously don't have, one to allow our auto-wrapping -// function, `withSentryAPI`, to pass the parameterized route into `withSentry`, and the other to prevent a manually -// wrapped route from being wrapped again by the auto-wrapper. - -export type NextApiHandler = { - (req: NextApiRequest, res: NextApiResponse): void | Promise | unknown | Promise; - __sentry_route__?: string; -}; - -export type WrappedNextApiHandler = { - (req: NextApiRequest, res: NextApiResponse): Promise | Promise; - __sentry_route__?: string; - __sentry_wrapped__?: boolean; -}; - -export type AugmentedNextApiRequest = NextApiRequest & { - __withSentry_applied__?: boolean; -}; - -export type AugmentedNextApiResponse = NextApiResponse & { - __sentryTransaction?: SentrySpan; -}; - -export type ResponseEndMethod = AugmentedNextApiResponse['end']; -export type WrappedResponseEndMethod = AugmentedNextApiResponse['end'] & WrappedFunction; diff --git a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts b/packages/nextjs/src/common/utils/edgeWrapperUtils.ts deleted file mode 100644 index 65bdabc93dda..000000000000 --- a/packages/nextjs/src/common/utils/edgeWrapperUtils.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_OK, - captureException, - continueTrace, - handleCallbackErrors, - setHttpStatus, - startSpan, - withIsolationScope, -} from '@sentry/core'; -import { winterCGRequestToRequestData } from '@sentry/utils'; - -import type { EdgeRouteHandler } from '../../edge/types'; -import { flushSafelyWithTimeout } from './responseEnd'; -import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils'; -import { vercelWaitUntil } from './vercelWaitUntil'; - -/** - * Wraps a function on the edge runtime with error and performance monitoring. - */ -export function withEdgeWrapping( - handler: H, - options: { spanDescription: string; spanOp: string; mechanismFunctionName: string }, -): (...params: Parameters) => Promise> { - return async function (this: unknown, ...args) { - return escapeNextjsTracing(() => { - const req: unknown = args[0]; - return withIsolationScope(commonObjectToIsolationScope(req), isolationScope => { - let sentryTrace; - let baggage; - - if (req instanceof Request) { - sentryTrace = req.headers.get('sentry-trace') || ''; - baggage = req.headers.get('baggage'); - - isolationScope.setSDKProcessingMetadata({ - request: winterCGRequestToRequestData(req), - }); - } - - isolationScope.setTransactionName(options.spanDescription); - - return continueTrace( - { - sentryTrace, - baggage, - }, - () => { - return startSpan( - { - name: options.spanDescription, - op: options.spanOp, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - }, - async span => { - const handlerResult = await handleCallbackErrors( - () => handler.apply(this, args), - error => { - captureException(error, { - mechanism: { - type: 'instrument', - handled: false, - data: { - function: options.mechanismFunctionName, - }, - }, - }); - }, - ); - - if (handlerResult instanceof Response) { - setHttpStatus(span, handlerResult.status); - } else { - span.setStatus({ code: SPAN_STATUS_OK }); - } - - return handlerResult; - }, - ); - }, - ).finally(() => { - vercelWaitUntil(flushSafelyWithTimeout()); - }); - }); - }); - }; -} diff --git a/packages/nextjs/src/common/utils/isBuild.ts b/packages/nextjs/src/common/utils/isBuild.ts deleted file mode 100644 index 92b9808f75b9..000000000000 --- a/packages/nextjs/src/common/utils/isBuild.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { PHASE_PRODUCTION_BUILD } from 'next/constants'; - -/** - * Decide if the currently running process is part of the build phase or happening at runtime. - */ -export function isBuild(): boolean { - return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD; -} diff --git a/packages/nextjs/src/common/utils/responseEnd.ts b/packages/nextjs/src/common/utils/responseEnd.ts deleted file mode 100644 index b59dbf0ce170..000000000000 --- a/packages/nextjs/src/common/utils/responseEnd.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ServerResponse } from 'http'; -import { flush, setHttpStatus } from '@sentry/core'; -import type { Span } from '@sentry/types'; -import { fill, logger } from '@sentry/utils'; - -import { DEBUG_BUILD } from '../debug-build'; -import type { ResponseEndMethod, WrappedResponseEndMethod } from '../types'; - -/** - * Wrap `res.end()` so that it ends the span and flushes events before letting the request finish. - * - * Note: This wraps a sync method with an async method. While in general that's not a great idea in terms of keeping - * things in the right order, in this case it's safe, because the native `.end()` actually *is* (effectively) async, and - * its run actually *is* (literally) awaited, just manually so (which reflects the fact that the core of the - * request/response code in Node by far predates the introduction of `async`/`await`). When `.end()` is done, it emits - * the `prefinish` event, and only once that fires does request processing continue. See - * https://github.com/nodejs/node/commit/7c9b607048f13741173d397795bac37707405ba7. - * - * Also note: `res.end()` isn't called until *after* all response data and headers have been sent, so blocking inside of - * `end` doesn't delay data getting to the end user. See - * https://nodejs.org/api/http.html#responseenddata-encoding-callback. - * - * @param span The span tracking the request - * @param res: The request's corresponding response - */ -export function autoEndSpanOnResponseEnd(span: Span, res: ServerResponse): void { - const wrapEndMethod = (origEnd: ResponseEndMethod): WrappedResponseEndMethod => { - return function sentryWrappedEnd(this: ServerResponse, ...args: unknown[]) { - finishSpan(span, this); - return origEnd.call(this, ...args); - }; - }; - - // Prevent double-wrapping - // res.end may be undefined during build when using `next export` to statically export a Next.js app - if (res.end && !(res.end as WrappedResponseEndMethod).__sentry_original__) { - fill(res, 'end', wrapEndMethod); - } -} - -/** Finish the given response's span and set HTTP status data */ -export function finishSpan(span: Span, res: ServerResponse): void { - setHttpStatus(span, res.statusCode); - span.end(); -} - -/** - * Flushes pending Sentry events with a 2 second timeout and in a way that cannot create unhandled promise rejections. - */ -export async function flushSafelyWithTimeout(): Promise { - try { - DEBUG_BUILD && logger.log('Flushing events...'); - await flush(2000); - DEBUG_BUILD && logger.log('Done flushing events'); - } catch (e) { - DEBUG_BUILD && logger.log('Error while flushing events:\n', e); - } -} diff --git a/packages/nextjs/src/common/utils/tracingUtils.ts b/packages/nextjs/src/common/utils/tracingUtils.ts deleted file mode 100644 index b996b6af1877..000000000000 --- a/packages/nextjs/src/common/utils/tracingUtils.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Scope, startNewTrace } from '@sentry/core'; -import type { PropagationContext } from '@sentry/types'; -import { GLOBAL_OBJ, logger } from '@sentry/utils'; -import { DEBUG_BUILD } from '../debug-build'; - -const commonPropagationContextMap = new WeakMap(); - -/** - * Takes a shared (garbage collectable) object between resources, e.g. a headers object shared between Next.js server components and returns a common propagation context. - * - * @param commonObject The shared object. - * @param propagationContext The propagation context that should be shared between all the resources if no propagation context was registered yet. - * @returns the shared propagation context. - */ -export function commonObjectToPropagationContext( - commonObject: unknown, - propagationContext: PropagationContext, -): PropagationContext { - if (typeof commonObject === 'object' && commonObject) { - const memoPropagationContext = commonPropagationContextMap.get(commonObject); - if (memoPropagationContext) { - return memoPropagationContext; - } else { - commonPropagationContextMap.set(commonObject, propagationContext); - return propagationContext; - } - } else { - return propagationContext; - } -} - -const commonIsolationScopeMap = new WeakMap(); - -/** - * Takes a shared (garbage collectable) object between resources, e.g. a headers object shared between Next.js server components and returns a common propagation context. - * - * @param commonObject The shared object. - * @param isolationScope The isolationScope that should be shared between all the resources if no isolation scope was created yet. - * @returns the shared isolation scope. - */ -export function commonObjectToIsolationScope(commonObject: unknown): Scope { - if (typeof commonObject === 'object' && commonObject) { - const memoIsolationScope = commonIsolationScopeMap.get(commonObject); - if (memoIsolationScope) { - return memoIsolationScope; - } else { - const newIsolationScope = new Scope(); - commonIsolationScopeMap.set(commonObject, newIsolationScope); - return newIsolationScope; - } - } else { - return new Scope(); - } -} - -interface AsyncLocalStorage { - getStore(): T | undefined; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - run(store: T, callback: (...args: TArgs) => R, ...args: TArgs): R; -} - -let nextjsEscapedAsyncStorage: AsyncLocalStorage; - -/** - * Will mark the execution context of the callback as "escaped" from Next.js internal tracing by unsetting the active - * span and propagation context. When an execution passes through this function multiple times, it is a noop after the - * first time. - */ -export function escapeNextjsTracing(cb: () => T): T { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - const MaybeGlobalAsyncLocalStorage = (GLOBAL_OBJ as any).AsyncLocalStorage; - - if (!MaybeGlobalAsyncLocalStorage) { - DEBUG_BUILD && - logger.warn( - "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", - ); - return cb(); - } - - if (!nextjsEscapedAsyncStorage) { - nextjsEscapedAsyncStorage = new MaybeGlobalAsyncLocalStorage(); - } - - if (nextjsEscapedAsyncStorage.getStore()) { - return cb(); - } else { - return startNewTrace(() => { - return nextjsEscapedAsyncStorage.run(true, () => { - return cb(); - }); - }); - } -} diff --git a/packages/nextjs/src/common/utils/vercelWaitUntil.ts b/packages/nextjs/src/common/utils/vercelWaitUntil.ts deleted file mode 100644 index 15c6015fe4c9..000000000000 --- a/packages/nextjs/src/common/utils/vercelWaitUntil.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { GLOBAL_OBJ } from '@sentry/utils'; - -interface VercelRequestContextGlobal { - get?(): { - waitUntil?: (task: Promise) => void; - }; -} - -/** - * Function that delays closing of a Vercel lambda until the provided promise is resolved. - * - * Vendored from https://www.npmjs.com/package/@vercel/functions - */ -export function vercelWaitUntil(task: Promise): void { - const vercelRequestContextGlobal: VercelRequestContextGlobal | undefined = - // @ts-expect-error This is not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')]; - - const ctx = vercelRequestContextGlobal?.get?.() ?? {}; - ctx.waitUntil?.(task); -} diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts deleted file mode 100644 index f07970e4db3b..000000000000 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - captureException, - continueTrace, - getTraceData, - startInactiveSpan, - startSpan, - startSpanManual, - withActiveSpan, - withIsolationScope, -} from '@sentry/core'; -import type { Span } from '@sentry/types'; -import { isString } from '@sentry/utils'; - -import { autoEndSpanOnResponseEnd, flushSafelyWithTimeout } from './responseEnd'; -import { commonObjectToIsolationScope, escapeNextjsTracing } from './tracingUtils'; -import { vercelWaitUntil } from './vercelWaitUntil'; - -declare module 'http' { - interface IncomingMessage { - _sentrySpan?: Span; - } -} - -/** - * Grabs a span off a Next.js datafetcher request object, if it was previously put there via - * `setSpanOnRequest`. - * - * @param req The Next.js datafetcher request object - * @returns the span on the request object if there is one, or `undefined` if the request object didn't have one. - */ -export function getSpanFromRequest(req: IncomingMessage): Span | undefined { - return req._sentrySpan; -} - -function setSpanOnRequest(span: Span, req: IncomingMessage): void { - req._sentrySpan = span; -} - -/** - * Wraps a function that potentially throws. If it does, the error is passed to `captureException` and rethrown. - * - * Note: This function turns the wrapped function into an asynchronous one. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withErrorInstrumentation any>( - origFunction: F, -): (...params: Parameters) => Promise> { - return async function (this: unknown, ...origFunctionArguments: Parameters): Promise> { - try { - return await origFunction.apply(this, origFunctionArguments); - } catch (e) { - // TODO: Extract error logic from `withSentry` in here or create a new wrapper with said logic or something like that. - captureException(e, { mechanism: { handled: false } }); - - throw e; - } - }; -} - -/** - * Calls a server-side data fetching function (that takes a `req` and `res` object in its context) with tracing - * instrumentation. A transaction will be created for the incoming request (if it doesn't already exist) in addition to - * a span for the wrapped data fetching function. - * - * All of the above happens in an isolated domain, meaning all thrown errors will be associated with the correct span. - * - * @param origDataFetcher The data fetching method to call. - * @param origFunctionArguments The arguments to call the data fetching method with. - * @param req The data fetching function's request object. - * @param res The data fetching function's response object. - * @param options Options providing details for the created transaction and span. - * @returns what the data fetching method call returned. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withTracedServerSideDataFetcher Promise | any>( - origDataFetcher: F, - req: IncomingMessage, - res: ServerResponse, - options: { - /** Parameterized route of the request - will be used for naming the transaction. */ - requestedRouteName: string; - /** Name of the route the data fetcher was defined in - will be used for describing the data fetcher's span. */ - dataFetcherRouteName: string; - /** Name of the data fetching method - will be used for describing the data fetcher's span. */ - dataFetchingMethodName: string; - }, -): (...params: Parameters) => Promise<{ data: ReturnType; sentryTrace?: string; baggage?: string }> { - return async function ( - this: unknown, - ...args: Parameters - ): Promise<{ data: ReturnType; sentryTrace?: string; baggage?: string }> { - return escapeNextjsTracing(() => { - const isolationScope = commonObjectToIsolationScope(req); - return withIsolationScope(isolationScope, () => { - isolationScope.setTransactionName(`${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`); - isolationScope.setSDKProcessingMetadata({ - request: req, - }); - - const sentryTrace = - req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined; - const baggage = req.headers?.baggage; - - return continueTrace({ sentryTrace, baggage }, () => { - const requestSpan = getOrStartRequestSpan(req, res, options.requestedRouteName); - return withActiveSpan(requestSpan, () => { - return startSpanManual( - { - op: 'function.nextjs', - name: `${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }, - async dataFetcherSpan => { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_OK }); - const { 'sentry-trace': sentryTrace, baggage } = getTraceData(); - try { - return { - sentryTrace: sentryTrace, - baggage: baggage, - data: await origDataFetcher.apply(this, args), - }; - } catch (e) { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - requestSpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - throw e; - } finally { - dataFetcherSpan.end(); - } - }, - ); - }); - }); - }); - }).finally(() => { - vercelWaitUntil(flushSafelyWithTimeout()); - }); - }; -} - -function getOrStartRequestSpan(req: IncomingMessage, res: ServerResponse, name: string): Span { - const existingSpan = getSpanFromRequest(req); - if (existingSpan) { - return existingSpan; - } - - const requestSpan = startInactiveSpan({ - name, - forceTransaction: true, - op: 'http.server', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }); - - requestSpan.setStatus({ code: SPAN_STATUS_OK }); - setSpanOnRequest(requestSpan, req); - autoEndSpanOnResponseEnd(requestSpan, res); - - return requestSpan; -} - -/** - * Call a data fetcher and trace it. Only traces the function if there is an active transaction on the scope. - * - * We only do the following until we move transaction creation into this function: When called, the wrapped function - * will also update the name of the active transaction with a parameterized route provided via the `options` argument. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function callDataFetcherTraced Promise | any>( - origFunction: F, - origFunctionArgs: Parameters, - options: { - parameterizedRoute: string; - dataFetchingMethodName: string; - }, -): Promise> { - const { parameterizedRoute, dataFetchingMethodName } = options; - - return startSpan( - { - op: 'function.nextjs', - name: `${dataFetchingMethodName} (${parameterizedRoute})`, - onlyIfParent: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }, - async dataFetcherSpan => { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_OK }); - - try { - return await origFunction(...origFunctionArgs); - } catch (e) { - dataFetcherSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(e, { mechanism: { handled: false } }); - throw e; - } finally { - dataFetcherSpan.end(); - } - }, - ).finally(() => { - vercelWaitUntil(flushSafelyWithTimeout()); - }); -} diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts deleted file mode 100644 index 14c701638ee5..000000000000 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - getIsolationScope, - withIsolationScope, -} from '@sentry/core'; -import { captureException, continueTrace, getClient, handleCallbackErrors, startSpan } from '@sentry/core'; -import { logger } from '@sentry/utils'; - -import { DEBUG_BUILD } from './debug-build'; -import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { escapeNextjsTracing } from './utils/tracingUtils'; -import { vercelWaitUntil } from './utils/vercelWaitUntil'; - -interface Options { - formData?: FormData; - headers?: Headers; - recordResponse?: boolean; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withServerActionInstrumentation any>( - serverActionName: string, - callback: A, -): Promise>; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withServerActionInstrumentation any>( - serverActionName: string, - options: Options, - callback: A, -): Promise>; - -/** - * Wraps a Next.js Server Action implementation with Sentry Error and Performance instrumentation. - */ -export function withServerActionInstrumentation unknown>( - ...args: [string, Options, A] | [string, A] -): Promise> { - if (typeof args[1] === 'function') { - const [serverActionName, callback] = args; - return withServerActionInstrumentationImplementation(serverActionName, {}, callback); - } else { - const [serverActionName, options, callback] = args; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return withServerActionInstrumentationImplementation(serverActionName, options, callback!); - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function withServerActionInstrumentationImplementation any>( - serverActionName: string, - options: Options, - callback: A, -): Promise> { - return escapeNextjsTracing(() => { - return withIsolationScope(isolationScope => { - const sendDefaultPii = getClient()?.getOptions().sendDefaultPii; - - let sentryTraceHeader; - let baggageHeader; - const fullHeadersObject: Record = {}; - try { - sentryTraceHeader = options.headers?.get('sentry-trace') ?? undefined; - baggageHeader = options.headers?.get('baggage'); - options.headers?.forEach((value, key) => { - fullHeadersObject[key] = value; - }); - } catch (e) { - DEBUG_BUILD && - logger.warn( - "Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.", - ); - } - - isolationScope.setTransactionName(`serverAction/${serverActionName}`); - isolationScope.setSDKProcessingMetadata({ - request: { - headers: fullHeadersObject, - }, - }); - - return continueTrace( - { - sentryTrace: sentryTraceHeader, - baggage: baggageHeader, - }, - async () => { - try { - return await startSpan( - { - op: 'function.server_action', - name: `serverAction/${serverActionName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }, - async span => { - const result = await handleCallbackErrors(callback, error => { - if (isNotFoundNavigationError(error)) { - // We don't want to report "not-found"s - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(error)) { - // Don't do anything for redirects - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - }, - }); - } - }); - - if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) { - getIsolationScope().setExtra('server_action_result', result); - } - - if (options.formData) { - options.formData.forEach((value, key) => { - getIsolationScope().setExtra( - `server_action_form_data.${key}`, - typeof value === 'string' ? value : '[non-string value]', - ); - }); - } - - return result; - }, - ); - } finally { - vercelWaitUntil(flushSafelyWithTimeout()); - } - }, - ); - }); - }); -} diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts deleted file mode 100644 index 09bca8d23d78..000000000000 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - captureException, - continueTrace, - setHttpStatus, - startSpanManual, - withIsolationScope, -} from '@sentry/core'; -import { consoleSandbox, isString, logger, objectify } from '@sentry/utils'; - -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler } from './types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { escapeNextjsTracing } from './utils/tracingUtils'; -import { vercelWaitUntil } from './utils/vercelWaitUntil'; - -/** - * Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only - * applies it if it hasn't already been applied. - * - * @param apiHandler The handler exported from the user's API page route file, which may or may not already be - * wrapped with `withSentry` - * @param parameterizedRoute The page's parameterized route. - * @returns The wrapped handler - */ -export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameterizedRoute: string): NextApiHandler { - return new Proxy(apiHandler, { - apply: ( - wrappingTarget, - thisArg, - args: [AugmentedNextApiRequest | undefined, AugmentedNextApiResponse | undefined], - ) => { - return escapeNextjsTracing(() => { - const [req, res] = args; - - if (!req) { - logger.debug( - `Wrapped API handler on route "${parameterizedRoute}" was not passed a request object. Will not instrument.`, - ); - return wrappingTarget.apply(thisArg, args); - } else if (!res) { - logger.debug( - `Wrapped API handler on route "${parameterizedRoute}" was not passed a response object. Will not instrument.`, - ); - return wrappingTarget.apply(thisArg, args); - } - - // We're now auto-wrapping API route handlers using `wrapApiHandlerWithSentry` (which uses `withSentry` under the hood), but - // users still may have their routes manually wrapped with `withSentry`. This check makes `sentryWrappedHandler` - // idempotent so that those cases don't break anything. - if (req.__withSentry_applied__) { - return wrappingTarget.apply(thisArg, args); - } - req.__withSentry_applied__ = true; - - return withIsolationScope(isolationScope => { - return continueTrace( - { - // TODO(v8): Make it so that continue trace will allow null as sentryTrace value and remove this fallback here - sentryTrace: - req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined, - baggage: req.headers?.baggage, - }, - () => { - const reqMethod = `${(req.method || 'GET').toUpperCase()} `; - - isolationScope.setSDKProcessingMetadata({ request: req }); - isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`); - - return startSpanManual( - { - name: `${reqMethod}${parameterizedRoute}`, - op: 'http.server', - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', - }, - }, - async span => { - // eslint-disable-next-line @typescript-eslint/unbound-method - res.end = new Proxy(res.end, { - apply(target, thisArg, argArray) { - if (span.isRecording()) { - setHttpStatus(span, res.statusCode); - span.end(); - } - vercelWaitUntil(flushSafelyWithTimeout()); - target.apply(thisArg, argArray); - }, - }); - - try { - const handlerResult = await wrappingTarget.apply(thisArg, args); - if ( - process.env.NODE_ENV === 'development' && - !process.env.SENTRY_IGNORE_API_RESOLUTION_ERROR && - !res.writableEnded - ) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - '[sentry] If Next.js logs a warning "API resolved without sending a response", it\'s a false positive, which may happen when you use `wrapApiHandlerWithSentry` manually to wrap your routes. To suppress this warning, set `SENTRY_IGNORE_API_RESOLUTION_ERROR` to 1 in your env. To suppress the nextjs warning, use the `externalResolver` API route option (see https://nextjs.org/docs/api-routes/api-middlewares#custom-config for details).', - ); - }); - } - - return handlerResult; - } catch (e) { - // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can - // store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced - // to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a - // way to prevent it from actually being reported twice.) - const objectifiedErr = objectify(e); - - captureException(objectifiedErr, { - mechanism: { - type: 'instrument', - handled: false, - data: { - wrapped_handler: wrappingTarget.name, - function: 'withSentry', - }, - }, - }); - - // Because we're going to finish and send the transaction before passing the error onto nextjs, it won't yet - // have had a chance to set the status to 500, so unless we do it ourselves now, we'll incorrectly report that - // the transaction was error-free - res.statusCode = 500; - res.statusMessage = 'Internal Server Error'; - - if (span.isRecording()) { - setHttpStatus(span, res.statusCode); - span.end(); - } - - vercelWaitUntil(flushSafelyWithTimeout()); - - // We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it - // would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark - // the error as already having been captured.) - throw objectifiedErr; - } - }, - ); - }, - ); - }); - }); - }, - }); -} diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts deleted file mode 100644 index 4974cd827e9a..000000000000 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { captureCheckIn } from '@sentry/core'; -import type { NextApiRequest } from 'next'; - -import type { VercelCronsConfig } from './types'; - -type EdgeRequest = { - nextUrl: URL; - headers: Headers; -}; - -/** - * Wraps a function with Sentry crons instrumentation by automaticaly sending check-ins for the given Vercel crons config. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function wrapApiHandlerWithSentryVercelCrons any>( - handler: F, - vercelCronsConfig: VercelCronsConfig, -): F { - return new Proxy(handler, { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - apply: (originalFunction, thisArg, args: any[]) => { - if (!args || !args[0]) { - return originalFunction.apply(thisArg, args); - } - - const [req] = args as [NextApiRequest | EdgeRequest]; - - let maybePromiseResult; - const cronsKey = 'nextUrl' in req ? req.nextUrl.pathname : req.url; - const userAgentHeader = 'nextUrl' in req ? req.headers.get('user-agent') : req.headers['user-agent']; - - if ( - !vercelCronsConfig || // do nothing if vercel crons config is missing - !userAgentHeader?.includes('vercel-cron') // do nothing if endpoint is not called from vercel crons - ) { - return originalFunction.apply(thisArg, args); - } - - const vercelCron = vercelCronsConfig.find(vercelCron => vercelCron.path === cronsKey); - - if (!vercelCron || !vercelCron.path || !vercelCron.schedule) { - return originalFunction.apply(thisArg, args); - } - - const monitorSlug = vercelCron.path; - - const checkInId = captureCheckIn( - { - monitorSlug, - status: 'in_progress', - }, - { - maxRuntime: 60 * 12, // (minutes) so 12 hours - just a very high arbitrary number since we don't know the actual duration of the users cron job - schedule: { - type: 'crontab', - value: vercelCron.schedule, - }, - }, - ); - - const startTime = Date.now() / 1000; - - const handleErrorCase = (): void => { - captureCheckIn({ - checkInId, - monitorSlug, - status: 'error', - duration: Date.now() / 1000 - startTime, - }); - }; - - try { - maybePromiseResult = originalFunction.apply(thisArg, args); - } catch (e) { - handleErrorCase(); - throw e; - } - - if (typeof maybePromiseResult === 'object' && maybePromiseResult !== null && 'then' in maybePromiseResult) { - Promise.resolve(maybePromiseResult).then( - () => { - captureCheckIn({ - checkInId, - monitorSlug, - status: 'ok', - duration: Date.now() / 1000 - startTime, - }); - }, - () => { - handleErrorCase(); - }, - ); - - // It is very important that we return the original promise here, because Next.js attaches various properties - // to that promise and will throw if they are not on the returned value. - return maybePromiseResult; - } else { - captureCheckIn({ - checkInId, - monitorSlug, - status: 'ok', - duration: Date.now() / 1000 - startTime, - }); - return maybePromiseResult; - } - }, - }); -} diff --git a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts deleted file mode 100644 index 2c7b0adc7d7b..000000000000 --- a/packages/nextjs/src/common/wrapAppGetInitialPropsWithSentry.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type App from 'next/app'; - -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; - -type AppGetInitialProps = (typeof App)['getInitialProps']; - -/** - * Create a wrapped version of the user's exported `getInitialProps` function in - * a custom app ("_app.js"). - * - * @param origAppGetInitialProps The user's `getInitialProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export function wrapAppGetInitialPropsWithSentry(origAppGetInitialProps: AppGetInitialProps): AppGetInitialProps { - return new Proxy(origAppGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - if (isBuild()) { - return wrappingTarget.apply(thisArg, args); - } - - const [context] = args; - const { req, res } = context.ctx; - - const errorWrappedAppGetInitialProps = withErrorInstrumentation(wrappingTarget); - - // Generally we can assume that `req` and `res` are always defined on the server: - // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object - // This does not seem to be the case in dev mode. Because we have no clean way of associating the the data fetcher - // span with each other when there are no req or res objects, we simply do not trace them at all here. - if (req && res) { - const tracedGetInitialProps = withTracedServerSideDataFetcher(errorWrappedAppGetInitialProps, req, res, { - dataFetcherRouteName: '/_app', - requestedRouteName: context.ctx.pathname, - dataFetchingMethodName: 'getInitialProps', - }); - - const { - data: appGetInitialProps, - sentryTrace, - baggage, - }: { - data: { - pageProps: { - _sentryTraceData?: string; - _sentryBaggage?: string; - }; - }; - sentryTrace?: string; - baggage?: string; - } = await tracedGetInitialProps.apply(thisArg, args); - - // Per definition, `pageProps` is not optional, however an increased amount of users doesn't seem to call - // `App.getInitialProps(appContext)` in their custom `_app` pages which is required as per - // https://nextjs.org/docs/advanced-features/custom-app - resulting in missing `pageProps`. - // For this reason, we just handle the case where `pageProps` doesn't exist explicitly. - if (!appGetInitialProps.pageProps) { - appGetInitialProps.pageProps = {}; - } - - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (sentryTrace) { - appGetInitialProps.pageProps._sentryTraceData = sentryTrace; - } - - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (baggage) { - appGetInitialProps.pageProps._sentryBaggage = baggage; - } - - return appGetInitialProps; - } else { - return errorWrappedAppGetInitialProps.apply(thisArg, args); - } - }, - }); -} diff --git a/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts deleted file mode 100644 index 192e70f093b1..000000000000 --- a/packages/nextjs/src/common/wrapDocumentGetInitialPropsWithSentry.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type Document from 'next/document'; - -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; - -type DocumentGetInitialProps = typeof Document.getInitialProps; - -/** - * Create a wrapped version of the user's exported `getInitialProps` function in - * a custom document ("_document.js"). - * - * @param origDocumentGetInitialProps The user's `getInitialProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export function wrapDocumentGetInitialPropsWithSentry( - origDocumentGetInitialProps: DocumentGetInitialProps, -): DocumentGetInitialProps { - return new Proxy(origDocumentGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - if (isBuild()) { - return wrappingTarget.apply(thisArg, args); - } - - const [context] = args; - const { req, res } = context; - - const errorWrappedGetInitialProps = withErrorInstrumentation(wrappingTarget); - // Generally we can assume that `req` and `res` are always defined on the server: - // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object - // This does not seem to be the case in dev mode. Because we have no clean way of associating the the data fetcher - // span with each other when there are no req or res objects, we simply do not trace them at all here. - if (req && res) { - const tracedGetInitialProps = withTracedServerSideDataFetcher(errorWrappedGetInitialProps, req, res, { - dataFetcherRouteName: '/_document', - requestedRouteName: context.pathname, - dataFetchingMethodName: 'getInitialProps', - }); - - const { data } = await tracedGetInitialProps.apply(thisArg, args); - return data; - } else { - return errorWrappedGetInitialProps.apply(thisArg, args); - } - }, - }); -} diff --git a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts deleted file mode 100644 index a2bd559342a4..000000000000 --- a/packages/nextjs/src/common/wrapErrorGetInitialPropsWithSentry.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { NextPageContext } from 'next'; -import type { ErrorProps } from 'next/error'; - -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; - -type ErrorGetInitialProps = (context: NextPageContext) => Promise; - -/** - * Create a wrapped version of the user's exported `getInitialProps` function in - * a custom error page ("_error.js"). - * - * @param origErrorGetInitialProps The user's `getInitialProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export function wrapErrorGetInitialPropsWithSentry( - origErrorGetInitialProps: ErrorGetInitialProps, -): ErrorGetInitialProps { - return new Proxy(origErrorGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - if (isBuild()) { - return wrappingTarget.apply(thisArg, args); - } - - const [context] = args; - const { req, res } = context; - - const errorWrappedGetInitialProps = withErrorInstrumentation(wrappingTarget); - // Generally we can assume that `req` and `res` are always defined on the server: - // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object - // This does not seem to be the case in dev mode. Because we have no clean way of associating the the data fetcher - // span with each other when there are no req or res objects, we simply do not trace them at all here. - if (req && res) { - const tracedGetInitialProps = withTracedServerSideDataFetcher(errorWrappedGetInitialProps, req, res, { - dataFetcherRouteName: '/_error', - requestedRouteName: context.pathname, - dataFetchingMethodName: 'getInitialProps', - }); - - const { - data: errorGetInitialProps, - baggage, - sentryTrace, - }: { - data: ErrorProps & { - _sentryTraceData?: string; - _sentryBaggage?: string; - }; - baggage?: string; - sentryTrace?: string; - } = await tracedGetInitialProps.apply(thisArg, args); - - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (sentryTrace) { - errorGetInitialProps._sentryTraceData = sentryTrace; - } - - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (baggage) { - errorGetInitialProps._sentryBaggage = baggage; - } - - return errorGetInitialProps; - } else { - return errorWrappedGetInitialProps.apply(thisArg, args); - } - }, - }); -} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts deleted file mode 100644 index 5944b520f6ea..000000000000 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - Scope, - captureException, - getActiveSpan, - getCapturedScopesOnSpan, - getClient, - getRootSpan, - handleCallbackErrors, - setCapturedScopesOnSpan, - startSpanManual, - withIsolationScope, - withScope, -} from '@sentry/core'; -import type { WebFetchHeaders } from '@sentry/types'; -import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@sentry/utils'; - -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import type { GenerationFunctionContext } from '../common/types'; -import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; - -/** - * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function wrapGenerationFunctionWithSentry any>( - generationFunction: F, - context: GenerationFunctionContext, -): F { - const { requestAsyncStorage, componentRoute, componentType, generationFunctionIdentifier } = context; - return new Proxy(generationFunction, { - apply: (originalFunction, thisArg, args) => { - const requestTraceId = getActiveSpan()?.spanContext().traceId; - let headers: WebFetchHeaders | undefined = undefined; - // We try-catch here just in case anything goes wrong with the async storage here goes wrong since it is Next.js internal API - try { - headers = requestAsyncStorage?.getStore()?.headers; - } catch (e) { - /** empty */ - } - - const isolationScope = commonObjectToIsolationScope(headers); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const { scope } = getCapturedScopesOnSpan(rootSpan); - setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); - - // We mark the root span as an app router span so we can allow-list it in our span processor that would normally filter out all Next.js transactions/spans - rootSpan.setAttribute('sentry.rsc', true); - } - - let data: Record | undefined = undefined; - if (getClient()?.getOptions().sendDefaultPii) { - const props: unknown = args[0]; - const params = props && typeof props === 'object' && 'params' in props ? props.params : undefined; - const searchParams = - props && typeof props === 'object' && 'searchParams' in props ? props.searchParams : undefined; - data = { params, searchParams }; - } - - const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - - return withIsolationScope(isolationScope, () => { - return withScope(scope => { - scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); - - isolationScope.setSDKProcessingMetadata({ - request: { - headers: headersDict, - }, - }); - - const propagationContext = commonObjectToPropagationContext( - headers, - headersDict?.['sentry-trace'] - ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) - : { - traceId: requestTraceId || uuid4(), - spanId: uuid4().substring(16), - }, - ); - scope.setPropagationContext(propagationContext); - - scope.setExtra('route_data', data); - - return startSpanManual( - { - op: 'function.nextjs', - name: `${componentType}.${generationFunctionIdentifier} (${componentRoute})`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - }, - }, - span => { - return handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - err => { - // When you read this code you might think: "Wait a minute, shouldn't we set the status on the root span too?" - // The answer is: "No." - The status of the root span is determined by whatever status code Next.js decides to put on the response. - if (isNotFoundNavigationError(err)) { - // We don't want to report "not-found"s - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - getRootSpan(span).setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(err)) { - // We don't want to report redirects - span.setStatus({ code: SPAN_STATUS_OK }); - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - getRootSpan(span).setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(err, { - mechanism: { - handled: false, - }, - }); - } - }, - () => { - span.end(); - }, - ); - }, - ); - }); - }); - }, - }); -} diff --git a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts b/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts deleted file mode 100644 index 2624aefb4d24..000000000000 --- a/packages/nextjs/src/common/wrapGetInitialPropsWithSentry.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { NextPage } from 'next'; - -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; - -type GetInitialProps = Required['getInitialProps']; - -/** - * Create a wrapped version of the user's exported `getInitialProps` function - * - * @param origGetInitialProps The user's `getInitialProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export function wrapGetInitialPropsWithSentry(origGetInitialProps: GetInitialProps): GetInitialProps { - return new Proxy(origGetInitialProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - if (isBuild()) { - return wrappingTarget.apply(thisArg, args); - } - - const [context] = args; - const { req, res } = context; - - const errorWrappedGetInitialProps = withErrorInstrumentation(wrappingTarget); - // Generally we can assume that `req` and `res` are always defined on the server: - // https://nextjs.org/docs/api-reference/data-fetching/get-initial-props#context-object - // This does not seem to be the case in dev mode. Because we have no clean way of associating the the data fetcher - // span with each other when there are no req or res objects, we simply do not trace them at all here. - if (req && res) { - const tracedGetInitialProps = withTracedServerSideDataFetcher(errorWrappedGetInitialProps, req, res, { - dataFetcherRouteName: context.pathname, - requestedRouteName: context.pathname, - dataFetchingMethodName: 'getInitialProps', - }); - - const { - data: initialProps, - baggage, - sentryTrace, - }: { - data: { - _sentryTraceData?: string; - _sentryBaggage?: string; - }; - baggage?: string; - sentryTrace?: string; - } = (await tracedGetInitialProps.apply(thisArg, args)) ?? {}; // Next.js allows undefined to be returned from a getInitialPropsFunction. - - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (sentryTrace) { - initialProps._sentryTraceData = sentryTrace; - } - - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (baggage) { - initialProps._sentryBaggage = baggage; - } - - return initialProps; - } else { - return errorWrappedGetInitialProps.apply(thisArg, args); - } - }, - }); -} diff --git a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts b/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts deleted file mode 100644 index 0037bad36300..000000000000 --- a/packages/nextjs/src/common/wrapGetServerSidePropsWithSentry.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { GetServerSideProps } from 'next'; - -import { isBuild } from './utils/isBuild'; -import { withErrorInstrumentation, withTracedServerSideDataFetcher } from './utils/wrapperUtils'; - -/** - * Create a wrapped version of the user's exported `getServerSideProps` function - * - * @param origGetServerSideProps The user's `getServerSideProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export function wrapGetServerSidePropsWithSentry( - origGetServerSideProps: GetServerSideProps, - parameterizedRoute: string, -): GetServerSideProps { - return new Proxy(origGetServerSideProps, { - apply: async (wrappingTarget, thisArg, args: Parameters) => { - if (isBuild()) { - return wrappingTarget.apply(thisArg, args); - } - - const [context] = args; - const { req, res } = context; - - const errorWrappedGetServerSideProps = withErrorInstrumentation(wrappingTarget); - const tracedGetServerSideProps = withTracedServerSideDataFetcher(errorWrappedGetServerSideProps, req, res, { - dataFetcherRouteName: parameterizedRoute, - requestedRouteName: parameterizedRoute, - dataFetchingMethodName: 'getServerSideProps', - }); - - const { - data: serverSideProps, - baggage, - sentryTrace, - } = await (tracedGetServerSideProps.apply(thisArg, args) as ReturnType); - - if (serverSideProps && 'props' in serverSideProps) { - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (sentryTrace) { - (serverSideProps.props as Record)._sentryTraceData = sentryTrace; - } - - // The Next.js serializer throws on undefined values so we need to guard for it (#12102) - if (baggage) { - (serverSideProps.props as Record)._sentryBaggage = baggage; - } - } - - return serverSideProps; - }, - }); -} diff --git a/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts b/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts deleted file mode 100644 index aebbf42ac684..000000000000 --- a/packages/nextjs/src/common/wrapGetStaticPropsWithSentry.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { GetStaticProps } from 'next'; - -import { isBuild } from './utils/isBuild'; -import { callDataFetcherTraced, withErrorInstrumentation } from './utils/wrapperUtils'; - -type Props = { [key: string]: unknown }; - -/** - * Create a wrapped version of the user's exported `getStaticProps` function - * - * @param origGetStaticProps The user's `getStaticProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export function wrapGetStaticPropsWithSentry( - origGetStaticPropsa: GetStaticProps, - parameterizedRoute: string, -): GetStaticProps { - return new Proxy(origGetStaticPropsa, { - apply: async (wrappingTarget, thisArg, args: Parameters>) => { - if (isBuild()) { - return wrappingTarget.apply(thisArg, args); - } - - const errorWrappedGetStaticProps = withErrorInstrumentation(wrappingTarget); - return callDataFetcherTraced(errorWrappedGetStaticProps, args, { - parameterizedRoute, - dataFetchingMethodName: 'getStaticProps', - }); - }, - }); -} diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts deleted file mode 100644 index 66cbbb046300..000000000000 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { EdgeRouteHandler } from '../edge/types'; -import { withEdgeWrapping } from './utils/edgeWrapperUtils'; - -/** - * Wraps Next.js middleware with Sentry error and performance instrumentation. - * - * @param middleware The middleware handler. - * @returns a wrapped middleware handler. - */ -export function wrapMiddlewareWithSentry( - middleware: H, -): (...params: Parameters) => Promise> { - return new Proxy(middleware, { - apply: (wrappingTarget, thisArg, args: Parameters) => { - return withEdgeWrapping(wrappingTarget, { - spanDescription: 'middleware', - spanOp: 'middleware.nextjs', - mechanismFunctionName: 'withSentryMiddleware', - }).apply(thisArg, args); - }, - }); -} diff --git a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts deleted file mode 100644 index 8cd4a250ac14..000000000000 --- a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { captureException, getCurrentScope, withIsolationScope } from '@sentry/core'; -import { extractTraceparentData } from '@sentry/utils'; -import { escapeNextjsTracing } from './utils/tracingUtils'; - -interface FunctionComponent { - (...args: unknown[]): unknown; -} - -interface ClassComponent { - new (...args: unknown[]): { - props?: unknown; - render(...args: unknown[]): unknown; - }; -} - -function isReactClassComponent(target: unknown): target is ClassComponent { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return typeof target === 'function' && target?.prototype?.isReactComponent; -} - -/** - * Wraps a page component with Sentry error instrumentation. - */ -export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | ClassComponent): unknown { - if (isReactClassComponent(pageComponent)) { - return class SentryWrappedPageComponent extends pageComponent { - public render(...args: unknown[]): unknown { - return escapeNextjsTracing(() => { - return withIsolationScope(() => { - const scope = getCurrentScope(); - // We extract the sentry trace data that is put in the component props by datafetcher wrappers - const sentryTraceData = - typeof this.props === 'object' && - this.props !== null && - '_sentryTraceData' in this.props && - typeof this.props._sentryTraceData === 'string' - ? this.props._sentryTraceData - : undefined; - - if (sentryTraceData) { - const traceparentData = extractTraceparentData(sentryTraceData); - scope.setContext('trace', { - span_id: traceparentData?.parentSpanId, - trace_id: traceparentData?.traceId, - }); - } - - try { - return super.render(...args); - } catch (e) { - captureException(e, { - mechanism: { - handled: false, - }, - }); - throw e; - } - }); - }); - } - }; - } else if (typeof pageComponent === 'function') { - return new Proxy(pageComponent, { - apply(target, thisArg, argArray: [{ _sentryTraceData?: string } | undefined]) { - return escapeNextjsTracing(() => { - return withIsolationScope(() => { - const scope = getCurrentScope(); - // We extract the sentry trace data that is put in the component props by datafetcher wrappers - const sentryTraceData = argArray?.[0]?._sentryTraceData; - - if (sentryTraceData) { - const traceparentData = extractTraceparentData(sentryTraceData); - scope.setContext('trace', { - span_id: traceparentData?.parentSpanId, - trace_id: traceparentData?.traceId, - }); - } - - try { - return target.apply(thisArg, argArray); - } catch (e) { - captureException(e, { - mechanism: { - handled: false, - }, - }); - throw e; - } - }); - }); - }, - }); - } else { - return pageComponent; - } -} diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts deleted file mode 100644 index bf0d475603f2..000000000000 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - captureException, - handleCallbackErrors, - setHttpStatus, - startSpan, - withIsolationScope, - withScope, -} from '@sentry/core'; -import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; -import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import type { RouteHandlerContext } from './types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { - commonObjectToIsolationScope, - commonObjectToPropagationContext, - escapeNextjsTracing, -} from './utils/tracingUtils'; -import { vercelWaitUntil } from './utils/vercelWaitUntil'; - -/** - * Wraps a Next.js App Router Route handler with Sentry error and performance instrumentation. - * - * NOTICE: This wrapper is for App Router API routes. If you are looking to wrap Pages Router API routes use `wrapApiHandlerWithSentry` instead. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function wrapRouteHandlerWithSentry any>( - routeHandler: F, - context: RouteHandlerContext, -): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise> { - const { method, parameterizedRoute, headers } = context; - - return new Proxy(routeHandler, { - apply: (originalFunction, thisArg, args) => { - return escapeNextjsTracing(() => { - const isolationScope = commonObjectToIsolationScope(headers); - - const completeHeadersDict: Record = headers ? winterCGHeadersToDict(headers) : {}; - - isolationScope.setSDKProcessingMetadata({ - request: { - headers: completeHeadersDict, - }, - }); - - const incomingPropagationContext = propagationContextFromHeaders( - completeHeadersDict['sentry-trace'], - completeHeadersDict['baggage'], - ); - - const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext); - - return withIsolationScope(isolationScope, () => { - return withScope(async scope => { - scope.setTransactionName(`${method} ${parameterizedRoute}`); - scope.setPropagationContext(propagationContext); - try { - return startSpan( - { - name: `${method} ${parameterizedRoute}`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - }, - forceTransaction: true, - }, - async span => { - const response: Response = await handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - error => { - // Next.js throws errors when calling `redirect()`. We don't wanna report these. - if (isRedirectNavigationError(error)) { - // Don't do anything - } else if (isNotFoundNavigationError(error) && span) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else { - captureException(error, { - mechanism: { - handled: false, - }, - }); - } - }, - ); - - try { - if (span && response.status) { - setHttpStatus(span, response.status); - } - } catch { - // best effort - response may be undefined? - } - - return response; - }, - ); - } finally { - vercelWaitUntil(flushSafelyWithTimeout()); - } - }); - }); - }); - }, - }); -} diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts deleted file mode 100644 index e8d734a90ff3..000000000000 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SPAN_STATUS_ERROR, - SPAN_STATUS_OK, - Scope, - captureException, - getActiveSpan, - getCapturedScopesOnSpan, - getRootSpan, - handleCallbackErrors, - setCapturedScopesOnSpan, - startSpanManual, - withIsolationScope, - withScope, -} from '@sentry/core'; -import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@sentry/utils'; - -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; -import type { ServerComponentContext } from '../common/types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; -import { vercelWaitUntil } from './utils/vercelWaitUntil'; - -/** - * Wraps an `app` directory server component with Sentry error instrumentation. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function wrapServerComponentWithSentry any>( - appDirComponent: F, - context: ServerComponentContext, -): F { - const { componentRoute, componentType } = context; - // Even though users may define server components as async functions, for the client bundles - // Next.js will turn them into synchronous functions and it will transform any `await`s into instances of the `use` - // hook. 🤯 - return new Proxy(appDirComponent, { - apply: (originalFunction, thisArg, args) => { - const requestTraceId = getActiveSpan()?.spanContext().traceId; - const isolationScope = commonObjectToIsolationScope(context.headers); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const { scope } = getCapturedScopesOnSpan(rootSpan); - setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); - - // We mark the root span as an app router span so we can allow-list it in our span processor that would normally filter out all Next.js transactions/spans - rootSpan.setAttribute('sentry.rsc', true); - } - - const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; - - isolationScope.setSDKProcessingMetadata({ - request: { - headers: headersDict, - }, - }); - - return withIsolationScope(isolationScope, () => { - return withScope(scope => { - scope.setTransactionName(`${componentType} Server Component (${componentRoute})`); - - if (process.env.NEXT_RUNTIME === 'edge') { - const propagationContext = commonObjectToPropagationContext( - context.headers, - headersDict?.['sentry-trace'] - ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) - : { - traceId: requestTraceId || uuid4(), - spanId: uuid4().substring(16), - }, - ); - - scope.setPropagationContext(propagationContext); - } - - return startSpanManual( - { - op: 'function.nextjs', - name: `${componentType} Server Component (${componentRoute})`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - }, - }, - span => { - return handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - error => { - // When you read this code you might think: "Wait a minute, shouldn't we set the status on the root span too?" - // The answer is: "No." - The status of the root span is determined by whatever status code Next.js decides to put on the response. - if (isNotFoundNavigationError(error)) { - // We don't want to report "not-found"s - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(error)) { - // We don't want to report redirects - span.setStatus({ code: SPAN_STATUS_OK }); - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - }, - }); - } - }, - () => { - span.end(); - vercelWaitUntil(flushSafelyWithTimeout()); - }, - ); - }, - ); - }); - }); - }, - }); -} diff --git a/packages/nextjs/src/config/index.ts b/packages/nextjs/src/config/index.ts deleted file mode 100644 index d191fb9673e2..000000000000 --- a/packages/nextjs/src/config/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { withSentryConfig } from './withSentryConfig'; -export type { SentryBuildOptions } from './types'; diff --git a/packages/nextjs/src/config/loaders/index.ts b/packages/nextjs/src/config/loaders/index.ts deleted file mode 100644 index 322567c1495b..000000000000 --- a/packages/nextjs/src/config/loaders/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as valueInjectionLoader } from './valueInjectionLoader'; -export { default as prefixLoader } from './prefixLoader'; -export { default as wrappingLoader } from './wrappingLoader'; diff --git a/packages/nextjs/src/config/loaders/prefixLoader.ts b/packages/nextjs/src/config/loaders/prefixLoader.ts deleted file mode 100644 index a9ca78667b53..000000000000 --- a/packages/nextjs/src/config/loaders/prefixLoader.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { escapeStringForRegex } from '@sentry/utils'; - -import type { LoaderThis } from './types'; - -type LoaderOptions = { - templatePrefix: string; - replacements: Array<[string, string]>; -}; - -/** - * Inject templated code into the beginning of a module. - * - * Options: - * - `templatePrefix`: The XXX in `XXXPrefixLoaderTemplate.ts`, to specify which template to use - * - `replacements`: An array of tuples of the form `[, ]`, used for doing global - * string replacement in the template. Note: The replacement is done sequentially, in the order in which the - * replacement values are given. If any placeholder is a substring of any replacement value besides its own, make - * sure to order the tuples in such a way as to avoid over-replacement. - */ -export default function prefixLoader(this: LoaderThis, userCode: string): string { - // We know one or the other will be defined, depending on the version of webpack being used - const { templatePrefix, replacements } = 'getOptions' in this ? this.getOptions() : this.query; - - const templatePath = path.resolve(__dirname, `../templates/${templatePrefix}PrefixLoaderTemplate.js`); - // make sure the template is included when runing `webpack watch` - this.addDependency(templatePath); - - // Fill in placeholders - let templateCode = fs.readFileSync(templatePath).toString(); - replacements.forEach(([placeholder, value]) => { - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped - const placeholderRegex = new RegExp(escapeStringForRegex(placeholder), 'g'); - templateCode = templateCode.replace(placeholderRegex, value); - }); - - return `${templateCode}\n${userCode}`; -} diff --git a/packages/nextjs/src/config/loaders/types.ts b/packages/nextjs/src/config/loaders/types.ts deleted file mode 100644 index abb8b85bdecb..000000000000 --- a/packages/nextjs/src/config/loaders/types.ts +++ /dev/null @@ -1,60 +0,0 @@ -type LoaderCallback = ( - err: Error | undefined | null, - content?: string | Buffer, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sourceMap?: string | any, -) => void; - -export type LoaderThis = { - /** - * Path to the file being loaded - * - * https://webpack.js.org/api/loaders/#thisresourcepath - */ - resourcePath: string; - - /** - * Function to add outside file used by loader to `watch` process - * - * https://webpack.js.org/api/loaders/#thisadddependency - */ - addDependency: (filepath: string) => void; - - /** - * Marks a loader result as cacheable. - * - * https://webpack.js.org/api/loaders/#thiscacheable - */ - cacheable: (flag: boolean) => void; - - /** - * Marks a loader as asynchronous - * - * https://webpack.js.org/api/loaders/#thisasync - */ - async: () => undefined | LoaderCallback; - - /** - * Return errors, code, and sourcemaps from an asynchronous loader - * - * https://webpack.js.org/api/loaders/#thiscallback - */ - callback: LoaderCallback; -} & ( - | { - /** - * Loader options in Webpack 4 - * - * https://webpack.js.org/api/loaders/#thisquery - */ - query: Options; - } - | { - /** - * Loader options in Webpack 5 - * - * https://webpack.js.org/api/loaders/#thisgetoptionsschema - */ - getOptions: () => Options; - } -); diff --git a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts b/packages/nextjs/src/config/loaders/valueInjectionLoader.ts deleted file mode 100644 index bf89ce90ac2c..000000000000 --- a/packages/nextjs/src/config/loaders/valueInjectionLoader.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { LoaderThis } from './types'; - -type LoaderOptions = { - values: Record; -}; - -/** - * Set values on the global/window object at the start of a module. - * - * Options: - * - `values`: An object where the keys correspond to the keys of the global values to set and the values - * correspond to the values of the values on the global object. Values must be JSON serializable. - */ -export default function valueInjectionLoader(this: LoaderThis, userCode: string): string { - // We know one or the other will be defined, depending on the version of webpack being used - const { values } = 'getOptions' in this ? this.getOptions() : this.query; - - // We do not want to cache injected values across builds - this.cacheable(false); - - const injectedCode = Object.entries(values) - .map(([key, value]) => `globalThis["${key}"] = ${JSON.stringify(value)};`) - .join('\n'); - - return `${injectedCode}\n${userCode}`; -} diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts deleted file mode 100644 index ea7828497f95..000000000000 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ /dev/null @@ -1,362 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import commonjs from '@rollup/plugin-commonjs'; -import { stringMatchesSomePattern } from '@sentry/utils'; -import * as chalk from 'chalk'; -import type { RollupBuild, RollupError } from 'rollup'; -import { rollup } from 'rollup'; - -import type { ServerComponentContext, VercelCronsConfig } from '../../common/types'; -import type { LoaderThis } from './types'; - -// Just a simple placeholder to make referencing module consistent -const SENTRY_WRAPPER_MODULE_NAME = 'sentry-wrapper-module'; - -// Needs to end in .cjs in order for the `commonjs` plugin to pick it up -const WRAPPING_TARGET_MODULE_NAME = '__SENTRY_WRAPPING_TARGET_FILE__.cjs'; - -const apiWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'apiWrapperTemplate.js'); -const apiWrapperTemplateCode = fs.readFileSync(apiWrapperTemplatePath, { encoding: 'utf8' }); - -const pageWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'pageWrapperTemplate.js'); -const pageWrapperTemplateCode = fs.readFileSync(pageWrapperTemplatePath, { encoding: 'utf8' }); - -const middlewareWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'middlewareWrapperTemplate.js'); -const middlewareWrapperTemplateCode = fs.readFileSync(middlewareWrapperTemplatePath, { encoding: 'utf8' }); - -let showedMissingAsyncStorageModuleWarning = false; - -const serverComponentWrapperTemplatePath = path.resolve( - __dirname, - '..', - 'templates', - 'serverComponentWrapperTemplate.js', -); -const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' }); - -const routeHandlerWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'routeHandlerWrapperTemplate.js'); -const routeHandlerWrapperTemplateCode = fs.readFileSync(routeHandlerWrapperTemplatePath, { encoding: 'utf8' }); - -export type WrappingLoaderOptions = { - pagesDir: string | undefined; - appDir: string | undefined; - pageExtensionRegex: string; - excludeServerRoutes: Array; - wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler'; - vercelCronsConfig?: VercelCronsConfig; - nextjsRequestAsyncStorageModulePath?: string; -}; - -/** - * Replace the loaded file with a wrapped version the original file. In the wrapped version, the original file is loaded, - * any data-fetching functions (`getInitialProps`, `getStaticProps`, and `getServerSideProps`) or API routes it contains - * are wrapped, and then everything is re-exported. - */ -// eslint-disable-next-line complexity -export default function wrappingLoader( - this: LoaderThis, - userCode: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - userModuleSourceMap: any, -): void { - // We know one or the other will be defined, depending on the version of webpack being used - const { - pagesDir, - appDir, - pageExtensionRegex, - excludeServerRoutes = [], - wrappingTargetKind, - vercelCronsConfig, - nextjsRequestAsyncStorageModulePath, - } = 'getOptions' in this ? this.getOptions() : this.query; - - this.async(); - - let templateCode: string; - - if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') { - if (pagesDir === undefined) { - this.callback(null, userCode, userModuleSourceMap); - return; - } - - // Get the parameterized route name from this page's filepath - const parameterizedPagesRoute = path - // Get the path of the file insde of the pages directory - .relative(pagesDir, this.resourcePath) - // Replace all backslashes with forward slashes (windows) - .replace(/\\/g, '/') - // Add a slash at the beginning - .replace(/(.*)/, '/$1') - // Pull off the file extension - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- not end user input - .replace(new RegExp(`\\.(${pageExtensionRegex})`), '') - // Any page file named `index` corresponds to root of the directory its in, URL-wise, so turn `/xyz/index` into - // just `/xyz` - .replace(/\/index$/, '') - // In case all of the above have left us with an empty string (which will happen if we're dealing with the - // homepage), sub back in the root route - .replace(/^$/, '/'); - - // Skip explicitly-ignored pages - if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) { - this.callback(null, userCode, userModuleSourceMap); - return; - } - - if (wrappingTargetKind === 'page') { - templateCode = pageWrapperTemplateCode; - } else if (wrappingTargetKind === 'api-route') { - templateCode = apiWrapperTemplateCode; - } else { - throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`); - } - - templateCode = templateCode.replace(/__VERCEL_CRONS_CONFIGURATION__/g, JSON.stringify(vercelCronsConfig)); - - // Inject the route and the path to the file we're wrapping into the template - templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\')); - } else if (wrappingTargetKind === 'server-component' || wrappingTargetKind === 'route-handler') { - if (appDir === undefined) { - this.callback(null, userCode, userModuleSourceMap); - return; - } - - // Get the parameterized route name from this page's filepath - const parameterizedPagesRoute = path - // Get the path of the file insde of the app directory - .relative(appDir, this.resourcePath) - // Replace all backslashes with forward slashes (windows) - .replace(/\\/g, '/') - // Add a slash at the beginning - .replace(/(.*)/, '/$1') - // Pull off the file name - .replace(/\/[^/]+\.(js|ts|jsx|tsx)$/, '') - // In case all of the above have left us with an empty string (which will happen if we're dealing with the - // homepage), sub back in the root route - .replace(/^$/, '/'); - - // Skip explicitly-ignored pages - if (stringMatchesSomePattern(parameterizedPagesRoute, excludeServerRoutes, true)) { - this.callback(null, userCode, userModuleSourceMap); - return; - } - - // The following string is what Next.js injects in order to mark client components: - // https://github.com/vercel/next.js/blob/295f9da393f7d5a49b0c2e15a2f46448dbdc3895/packages/next/build/analysis/get-page-static-info.ts#L37 - // https://github.com/vercel/next.js/blob/a1c15d84d906a8adf1667332a3f0732be615afa0/packages/next-swc/crates/core/src/react_server_components.rs#L247 - // We do not want to wrap client components - if (userCode.includes('__next_internal_client_entry_do_not_use__')) { - this.callback(null, userCode, userModuleSourceMap); - return; - } - - if (wrappingTargetKind === 'server-component') { - templateCode = serverComponentWrapperTemplateCode; - } else { - templateCode = routeHandlerWrapperTemplateCode; - } - - if (nextjsRequestAsyncStorageModulePath !== undefined) { - templateCode = templateCode.replace( - /__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g, - nextjsRequestAsyncStorageModulePath, - ); - } else { - if (!showedMissingAsyncStorageModuleWarning) { - // eslint-disable-next-line no-console - console.warn( - `${chalk.yellow('warn')} - The Sentry SDK could not access the ${chalk.bold.cyan( - 'RequestAsyncStorage', - )} module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.\n`, - ); - showedMissingAsyncStorageModuleWarning = true; - } - templateCode = templateCode.replace( - /__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__/g, - '@sentry/nextjs/esm/config/templates/requestAsyncStorageShim.js', - ); - } - - templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\')); - - const componentTypeMatch = path.posix - .normalize(path.relative(appDir, this.resourcePath)) - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor - .match(new RegExp(`/\\/?([^/]+)\\.(?:${pageExtensionRegex})$`)); - - if (componentTypeMatch && componentTypeMatch[1]) { - let componentType: ServerComponentContext['componentType']; - switch (componentTypeMatch[1]) { - case 'page': - componentType = 'Page'; - break; - case 'layout': - componentType = 'Layout'; - break; - case 'head': - componentType = 'Head'; - break; - case 'not-found': - componentType = 'Not-found'; - break; - case 'loading': - componentType = 'Loading'; - break; - default: - componentType = 'Unknown'; - } - - templateCode = templateCode.replace(/__COMPONENT_TYPE__/g, componentType); - } else { - templateCode = templateCode.replace(/__COMPONENT_TYPE__/g, 'Unknown'); - } - } else if (wrappingTargetKind === 'middleware') { - templateCode = middlewareWrapperTemplateCode; - } else { - throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`); - } - - // Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand. - templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME); - - // Run the proxy module code through Rollup, in order to split the `export * from ''` out into - // individual exports (which nextjs seems to require). - wrapUserCode(templateCode, userCode, userModuleSourceMap) - .then(({ code: wrappedCode, map: wrappedCodeSourceMap }) => { - this.callback(null, wrappedCode, wrappedCodeSourceMap); - }) - .catch(err => { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] Could not instrument ${this.resourcePath}. An error occurred while auto-wrapping:\n${err}`, - ); - this.callback(null, userCode, userModuleSourceMap); - }); -} - -/** - * Use Rollup to process the proxy module code, in order to split its `export * from ''` call into - * individual exports (which nextjs seems to need). - * - * Wraps provided user code (located under the import defined via WRAPPING_TARGET_MODULE_NAME) with provided wrapper - * code. Under the hood, this function uses rollup to bundle the modules together. Rollup is convenient for us because - * it turns `export * from ''` (which Next.js doesn't allow) into individual named exports. - * - * Note: This function may throw in case something goes wrong while bundling. - * - * @param wrapperCode The wrapper module code - * @param userModuleCode The user module code - * @returns The wrapped user code and a source map that describes the transformations done by this function - */ -async function wrapUserCode( - wrapperCode: string, - userModuleCode: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - userModuleSourceMap: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise<{ code: string; map?: any }> { - const wrap = (withDefaultExport: boolean): Promise => - rollup({ - input: SENTRY_WRAPPER_MODULE_NAME, - - plugins: [ - // We're using a simple custom plugin that virtualizes our wrapper module and the user module, so we don't have to - // mess around with file paths and so that we can pass the original user module source map to rollup so that - // rollup gives us a bundle with correct source mapping to the original file - { - name: 'virtualize-sentry-wrapper-modules', - resolveId: id => { - if (id === SENTRY_WRAPPER_MODULE_NAME || id === WRAPPING_TARGET_MODULE_NAME) { - return id; - } else { - return null; - } - }, - load(id) { - if (id === SENTRY_WRAPPER_MODULE_NAME) { - return withDefaultExport ? wrapperCode : wrapperCode.replace('export { default } from', 'export {} from'); - } else if (id === WRAPPING_TARGET_MODULE_NAME) { - return { - code: userModuleCode, - map: userModuleSourceMap, // give rollup acces to original user module source map - }; - } else { - return null; - } - }, - }, - - // People may use `module.exports` in their API routes or page files. Next.js allows that and we also need to - // handle that correctly so we let a plugin to take care of bundling cjs exports for us. - commonjs({ - sourceMap: true, - strictRequires: true, // Don't hoist require statements that users may define - ignoreDynamicRequires: true, // Don't break dynamic requires and things like Webpack's `require.context` - ignore() { - // We basically only want to use this plugin for handling the case where users export their handlers with module.exports. - // This plugin would also be able to convert any `require` into something esm compatible but webpack does that anyways so we just skip that part of the plugin. - // (Also, modifying require may break user code) - return true; - }, - }), - ], - - // We only want to bundle our wrapper module and the wrappee module into one, so we mark everything else as external. - external: sourceId => sourceId !== SENTRY_WRAPPER_MODULE_NAME && sourceId !== WRAPPING_TARGET_MODULE_NAME, - - // Prevent rollup from stressing out about TS's use of global `this` when polyfilling await. (TS will polyfill if the - // user's tsconfig `target` is set to anything before `es2017`. See https://stackoverflow.com/a/72822340 and - // https://stackoverflow.com/a/60347490.) - context: 'this', - - // Rollup's path-resolution logic when handling re-exports can go wrong when wrapping pages which aren't at the root - // level of the `pages` directory. This may be a bug, as it doesn't match the behavior described in the docs, but what - // seems to happen is this: - // - // - We try to wrap `pages/xyz/userPage.js`, which contains `export { helperFunc } from '../../utils/helper'` - // - Rollup converts '../../utils/helper' into an absolute path - // - We mark the helper module as external - // - Rollup then converts it back to a relative path, but relative to `pages/` rather than `pages/xyz/`. (This is - // the part which doesn't match the docs. They say that Rollup will use the common ancestor of all modules in the - // bundle as the basis for the relative path calculation, but both our temporary file and the page being wrapped - // live in `pages/xyz/`, and they're the only two files in the bundle, so `pages/xyz/`` should be used as the - // root. Unclear why it's not.) - // - As a result of the miscalculation, our proxy module will include `export { helperFunc } from '../utils/helper'` - // rather than the expected `export { helperFunc } from '../../utils/helper'`, thereby causing a build error in - // nextjs.. - // - // Setting `makeAbsoluteExternalsRelative` to `false` prevents all of the above by causing Rollup to ignore imports of - // externals entirely, with the result that their paths remain untouched (which is what we want). - makeAbsoluteExternalsRelative: false, - onwarn: (_warning, _warn) => { - // Suppress all warnings - we don't want to bother people with this output - // Might be stuff like "you have unused imports" - // _warn(_warning); // uncomment to debug - }, - }); - - // Next.js sometimes complains if you define a default export (e.g. in route handlers in dev mode). - // This is why we want to avoid unnecessarily creating default exports, even if they're just `undefined`. - // For this reason we try to bundle/wrap the user code once including a re-export of `default`. - // If the user code didn't have a default export, rollup will throw. - // We then try bundling/wrapping agian, but without including a re-export of `default`. - let rollupBuild; - try { - rollupBuild = await wrap(true); - } catch (e) { - if ((e as RollupError)?.code === 'MISSING_EXPORT') { - rollupBuild = await wrap(false); - } else { - throw e; - } - } - - const finalBundle = await rollupBuild.generate({ - format: 'esm', - sourcemap: 'hidden', // put source map data in the bundle but don't generate a source map commment in the output - }); - - // The module at index 0 is always the entrypoint, which in this case is the proxy module. - return finalBundle.output[0]; -} diff --git a/packages/nextjs/src/config/templates/apiWrapperTemplate.ts b/packages/nextjs/src/config/templates/apiWrapperTemplate.ts deleted file mode 100644 index 80b9a4f51d60..000000000000 --- a/packages/nextjs/src/config/templates/apiWrapperTemplate.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * This file is a template for the code which will be substituted when our webpack loader handles API files in the - * `pages/` directory. - * - * We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. Because it's not a real package, - * this causes both TS and ESLint to complain, hence the pragma comments below. - */ - -import * as Sentry from '@sentry/nextjs'; -// @ts-expect-error See above -import * as origModule from '__SENTRY_WRAPPING_TARGET_FILE__'; -import type { PageConfig } from 'next'; - -import type { NextApiHandler, VercelCronsConfig } from '../../common/types'; - -type NextApiModule = ( - | { - // ESM export - default?: NextApiHandler; - } - // CJS export - | NextApiHandler -) & { config?: PageConfig }; - -const userApiModule = origModule as NextApiModule; - -// Default to undefined. It's possible for Next.js users to not define any exports/handlers in an API route. If that is -// the case Next.js wil crash during runtime but the Sentry SDK should definitely not crash so we need tohandle it. -let userProvidedHandler = undefined; - -if ('default' in userApiModule && typeof userApiModule.default === 'function') { - // Handle when user defines via ESM export: `export default myFunction;` - userProvidedHandler = userApiModule.default; -} else if (typeof userApiModule === 'function') { - // Handle when user defines via CJS export: "module.exports = myFunction;" - userProvidedHandler = userApiModule; -} - -const origConfig = userApiModule.config || {}; - -// Setting `externalResolver` to `true` prevents nextjs from throwing a warning in dev about API routes resolving -// without sending a response. It's a false positive (a response is sent, but only after we flush our send queue), and -// we throw a warning of our own to tell folks that, but it's better if we just don't have to deal with it in the first -// place. -export const config = { - ...origConfig, - api: { - ...origConfig.api, - externalResolver: true, - }, -}; - -declare const __VERCEL_CRONS_CONFIGURATION__: VercelCronsConfig; - -let wrappedHandler = userProvidedHandler; - -if (wrappedHandler && __VERCEL_CRONS_CONFIGURATION__) { - wrappedHandler = Sentry.wrapApiHandlerWithSentryVercelCrons(wrappedHandler, __VERCEL_CRONS_CONFIGURATION__); -} - -if (wrappedHandler) { - wrappedHandler = Sentry.wrapApiHandlerWithSentry(wrappedHandler, '__ROUTE__'); -} - -export default wrappedHandler; - -// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to -// not include anything whose name matchs something we've explicitly exported above. -// @ts-expect-error See above -export * from '__SENTRY_WRAPPING_TARGET_FILE__'; diff --git a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts deleted file mode 100644 index 83468b3120d8..000000000000 --- a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file is a template for the code which will be substituted when our webpack loader handles middleware files. - * - * We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. Because it's not a real package, - * this causes both TS and ESLint to complain, hence the pragma comments below. - */ - -import * as Sentry from '@sentry/nextjs'; -// @ts-expect-error See above -import * as origModule from '__SENTRY_WRAPPING_TARGET_FILE__'; - -import type { EdgeRouteHandler } from '../../edge/types'; - -type NextApiModule = - | { - // ESM export - default?: EdgeRouteHandler; - middleware?: EdgeRouteHandler; - } - // CJS export - | EdgeRouteHandler; - -const userApiModule = origModule as NextApiModule; - -// Default to undefined. It's possible for Next.js users to not define any exports/handlers in an API route. If that is -// the case Next.js wil crash during runtime but the Sentry SDK should definitely not crash so we need tohandle it. -let userProvidedNamedHandler: EdgeRouteHandler | undefined = undefined; -let userProvidedDefaultHandler: EdgeRouteHandler | undefined = undefined; - -if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') { - // Handle when user defines via named ESM export: `export { middleware };` - userProvidedNamedHandler = userApiModule.middleware; -} else if ('default' in userApiModule && typeof userApiModule.default === 'function') { - // Handle when user defines via ESM export: `export default myFunction;` - userProvidedDefaultHandler = userApiModule.default; -} else if (typeof userApiModule === 'function') { - // Handle when user defines via CJS export: "module.exports = myFunction;" - userProvidedDefaultHandler = userApiModule; -} - -export const middleware = userProvidedNamedHandler - ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) - : undefined; -export default userProvidedDefaultHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedDefaultHandler) : undefined; - -// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to -// not include anything whose name matchs something we've explicitly exported above. -// @ts-expect-error See above -export * from '__SENTRY_WRAPPING_TARGET_FILE__'; diff --git a/packages/nextjs/src/config/templates/pageWrapperTemplate.ts b/packages/nextjs/src/config/templates/pageWrapperTemplate.ts deleted file mode 100644 index 7ac89ed1931c..000000000000 --- a/packages/nextjs/src/config/templates/pageWrapperTemplate.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * This file is a template for the code which will be substituted when our webpack loader handles non-API files in the - * `pages/` directory. - * - * We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. Because it's not a real package, - * this causes both TS and ESLint to complain, hence the pragma comments below. - */ - -import * as Sentry from '@sentry/nextjs'; -// @ts-expect-error See above -import * as wrapee from '__SENTRY_WRAPPING_TARGET_FILE__'; -import type { GetServerSideProps, GetStaticProps, NextPage as NextPageComponent } from 'next'; - -type NextPageModule = { - default?: { getInitialProps?: NextPageComponent['getInitialProps'] }; - getStaticProps?: GetStaticProps; - getServerSideProps?: GetServerSideProps; -}; - -const userPageModule = wrapee as NextPageModule; - -const pageComponent = userPageModule ? userPageModule.default : undefined; - -const origGetInitialProps = pageComponent ? pageComponent.getInitialProps : undefined; -const origGetStaticProps = userPageModule ? userPageModule.getStaticProps : undefined; -const origGetServerSideProps = userPageModule ? userPageModule.getServerSideProps : undefined; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const getInitialPropsWrappers: Record = { - '/_app': Sentry.wrapAppGetInitialPropsWithSentry, - '/_document': Sentry.wrapDocumentGetInitialPropsWithSentry, - '/_error': Sentry.wrapErrorGetInitialPropsWithSentry, -}; - -const getInitialPropsWrapper = getInitialPropsWrappers['__ROUTE__'] || Sentry.wrapGetInitialPropsWithSentry; - -if (pageComponent && typeof origGetInitialProps === 'function') { - pageComponent.getInitialProps = getInitialPropsWrapper(origGetInitialProps) as NextPageComponent['getInitialProps']; -} - -export const getStaticProps = - typeof origGetStaticProps === 'function' - ? Sentry.wrapGetStaticPropsWithSentry(origGetStaticProps, '__ROUTE__') - : undefined; -export const getServerSideProps = - typeof origGetServerSideProps === 'function' - ? Sentry.wrapGetServerSidePropsWithSentry(origGetServerSideProps, '__ROUTE__') - : undefined; - -export default pageComponent ? Sentry.wrapPageComponentWithSentry(pageComponent as unknown) : pageComponent; - -// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to -// not include anything whose name matchs something we've explicitly exported above. -// @ts-expect-error See above -export * from '__SENTRY_WRAPPING_TARGET_FILE__'; diff --git a/packages/nextjs/src/config/templates/requestAsyncStorageShim.ts b/packages/nextjs/src/config/templates/requestAsyncStorageShim.ts deleted file mode 100644 index 4acb61e78444..000000000000 --- a/packages/nextjs/src/config/templates/requestAsyncStorageShim.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { WebFetchHeaders } from '@sentry/types'; - -export interface RequestAsyncStorage { - getStore: () => - | { - headers: WebFetchHeaders; - } - | undefined; -} diff --git a/packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts b/packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts deleted file mode 100644 index 346b2c29a784..000000000000 --- a/packages/nextjs/src/config/templates/routeHandlerWrapperTemplate.ts +++ /dev/null @@ -1,74 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; -import type { WebFetchHeaders } from '@sentry/types'; -// @ts-expect-error Because we cannot be sure if the RequestAsyncStorage module exists (it is not part of the Next.js public -// API) we use a shim if it doesn't exist. The logic for this is in the wrapping loader. -import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__'; -// @ts-expect-error See above -import * as routeModule from '__SENTRY_WRAPPING_TARGET_FILE__'; - -import type { RequestAsyncStorage } from './requestAsyncStorageShim'; - -function wrapHandler(handler: T, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'): T { - // Running the instrumentation code during the build phase will mark any function as "dynamic" because we're accessing - // the Request object. We do not want to turn handlers dynamic so we skip instrumentation in the build phase. - if (process.env.NEXT_PHASE === 'phase-production-build') { - return handler; - } - - if (typeof handler !== 'function') { - return handler; - } - - return new Proxy(handler, { - apply: (originalFunction, thisArg, args) => { - let sentryTraceHeader: string | undefined | null = undefined; - let baggageHeader: string | undefined | null = undefined; - let headers: WebFetchHeaders | undefined = undefined; - - // We try-catch here just in case the API around `requestAsyncStorage` changes unexpectedly since it is not public API - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const requestAsyncStore = requestAsyncStorage.getStore() as ReturnType; - sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined; - baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined; - headers = requestAsyncStore?.headers; - } catch (e) { - /** empty */ - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - return Sentry.wrapRouteHandlerWithSentry(originalFunction as any, { - method, - parameterizedRoute: '__ROUTE__', - sentryTraceHeader, - baggageHeader, - headers, - }).apply(thisArg, args); - }, - }); -} - -// @ts-expect-error See above -export * from '__SENTRY_WRAPPING_TARGET_FILE__'; - -// @ts-expect-error This is the file we're wrapping -export { default } from '__SENTRY_WRAPPING_TARGET_FILE__'; - -declare const requestAsyncStorage: RequestAsyncStorage; - -type RouteHandler = (...args: unknown[]) => unknown; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -export const GET = wrapHandler(routeModule.GET as RouteHandler, 'GET'); -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -export const POST = wrapHandler(routeModule.POST as RouteHandler, 'POST'); -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -export const PUT = wrapHandler(routeModule.PUT as RouteHandler, 'PUT'); -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -export const PATCH = wrapHandler(routeModule.PATCH as RouteHandler, 'PATCH'); -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -export const DELETE = wrapHandler(routeModule.DELETE as RouteHandler, 'DELETE'); -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -export const HEAD = wrapHandler(routeModule.HEAD as RouteHandler, 'HEAD'); -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -export const OPTIONS = wrapHandler(routeModule.OPTIONS as RouteHandler, 'OPTIONS'); diff --git a/packages/nextjs/src/config/templates/sentryInitWrapperTemplate.ts b/packages/nextjs/src/config/templates/sentryInitWrapperTemplate.ts deleted file mode 100644 index 3be1b07d6bc5..000000000000 --- a/packages/nextjs/src/config/templates/sentryInitWrapperTemplate.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-expect-error This will be replaced with the user's sentry config gile -import '__SENTRY_CONFIG_IMPORT_PATH__'; - -// @ts-expect-error This is the file we're wrapping -export * from '__SENTRY_WRAPPING_TARGET_FILE__'; - -// @ts-expect-error This is the file we're wrapping -export { default } from '__SENTRY_WRAPPING_TARGET_FILE__'; diff --git a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts deleted file mode 100644 index 717826e3a081..000000000000 --- a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; -import type { WebFetchHeaders } from '@sentry/types'; -// @ts-expect-error Because we cannot be sure if the RequestAsyncStorage module exists (it is not part of the Next.js public -// API) we use a shim if it doesn't exist. The logic for this is in the wrapping loader. -// biome-ignore lint/nursery/noUnusedImports: Biome doesn't understand the shim with variable import path -import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__'; -// @ts-expect-error We use `__SENTRY_WRAPPING_TARGET_FILE__` as a placeholder for the path to the file being wrapped. -// biome-ignore lint/nursery/noUnusedImports: Biome doesn't understand the shim with variable import path -import * as serverComponentModule from '__SENTRY_WRAPPING_TARGET_FILE__'; - -import type { RequestAsyncStorage } from './requestAsyncStorageShim'; - -declare const requestAsyncStorage: RequestAsyncStorage; - -declare const serverComponentModule: { - default: unknown; - generateMetadata?: () => unknown; - generateImageMetadata?: () => unknown; - generateViewport?: () => unknown; -}; - -const serverComponent = serverComponentModule.default; - -let wrappedServerComponent; -if (typeof serverComponent === 'function') { - // For some odd Next.js magic reason, `headers()` will not work if used inside `wrapServerComponentsWithSentry`. - // Current assumption is that Next.js applies some loader magic to userfiles, but not files in node_modules. This file - // is technically a userfile so it gets the loader magic applied. - wrappedServerComponent = new Proxy(serverComponent, { - apply: (originalFunction, thisArg, args) => { - let sentryTraceHeader: string | undefined | null = undefined; - let baggageHeader: string | undefined | null = undefined; - let headers: WebFetchHeaders | undefined = undefined; - - // We try-catch here just in `requestAsyncStorage` is undefined since it may not be defined - try { - const requestAsyncStore = requestAsyncStorage.getStore(); - sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined; - baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined; - headers = requestAsyncStore?.headers; - } catch (e) { - /** empty */ - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - return Sentry.wrapServerComponentWithSentry(originalFunction as any, { - componentRoute: '__ROUTE__', - componentType: '__COMPONENT_TYPE__', - sentryTraceHeader, - baggageHeader, - headers, - }).apply(thisArg, args); - }, - }); -} else { - wrappedServerComponent = serverComponent; -} - -export const generateMetadata = serverComponentModule.generateMetadata - ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateMetadata, { - componentRoute: '__ROUTE__', - componentType: '__COMPONENT_TYPE__', - generationFunctionIdentifier: 'generateMetadata', - requestAsyncStorage, - }) - : undefined; - -export const generateImageMetadata = serverComponentModule.generateImageMetadata - ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateImageMetadata, { - componentRoute: '__ROUTE__', - componentType: '__COMPONENT_TYPE__', - generationFunctionIdentifier: 'generateImageMetadata', - requestAsyncStorage, - }) - : undefined; - -export const generateViewport = serverComponentModule.generateViewport - ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateViewport, { - componentRoute: '__ROUTE__', - componentType: '__COMPONENT_TYPE__', - generationFunctionIdentifier: 'generateViewport', - requestAsyncStorage, - }) - : undefined; - -// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to -// not include anything whose name matchs something we've explicitly exported above. -// @ts-expect-error See above -export * from '__SENTRY_WRAPPING_TARGET_FILE__'; - -export default wrappedServerComponent; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts deleted file mode 100644 index d2e78d87f4ae..000000000000 --- a/packages/nextjs/src/config/types.ts +++ /dev/null @@ -1,553 +0,0 @@ -import type { GLOBAL_OBJ } from '@sentry/utils'; -import type { SentryWebpackPluginOptions } from '@sentry/webpack-plugin'; -import type { DefinePlugin, WebpackPluginInstance } from 'webpack'; - -// Export this from here because importing something from Webpack (the library) in `webpack.ts` confuses the heck out of -// madge, which we use for circular dependency checking. We've manually excluded this file from the check (which is -// safe, since it only includes types), so we can import it here without causing madge to fail. See -// https://github.com/pahen/madge/issues/306. -export type { WebpackPluginInstance }; - -// The first argument to `withSentryConfig` (which is the user's next config). -export type ExportedNextConfig = NextConfigObject | NextConfigFunction; - -// Vendored from Next.js (this type is not complete - extend if necessary) -type NextRewrite = { - source: string; - destination: string; -}; - -export type NextConfigObject = { - // Custom webpack options - webpack?: WebpackConfigFunction | null; - // Whether to build serverless functions for all pages, not just API routes. Removed in nextjs 12+. - target?: 'server' | 'experimental-serverless-trace'; - // The output directory for the built app (defaults to ".next") - distDir?: string; - // URL location of `_next/static` directory when hosted on a CDN - assetPrefix?: string; - // The root at which the nextjs app will be served (defaults to "/") - basePath?: string; - // Config which will be available at runtime - publicRuntimeConfig?: { [key: string]: unknown }; - // File extensions that count as pages in the `pages/` directory - pageExtensions?: string[]; - // Whether Next.js should do a static export - output?: string; - // Paths to reroute when requested - rewrites?: () => Promise< - | NextRewrite[] - | { - beforeFiles?: NextRewrite[]; - afterFiles?: NextRewrite[]; - fallback?: NextRewrite[]; - } - >; - // Next.js experimental options - experimental?: { - instrumentationHook?: boolean; - clientTraceMetadata?: string[]; - }; - productionBrowserSourceMaps?: boolean; -}; - -export type SentryBuildOptions = { - /** - * The slug of the Sentry organization associated with the app. - * - * This value can also be specified via the `SENTRY_ORG` environment variable. - */ - org?: string; - - /** - * The slug of the Sentry project associated with the app. - * - * This value can also be specified via the `SENTRY_PROJECT` environment variable. - */ - project?: string; - - /** - * The authentication token to use for all communication with Sentry. - * Can be obtained from https://sentry.io/orgredirect/organizations/:orgslug/settings/auth-tokens/. - * - * This value can also be specified via the `SENTRY_AUTH_TOKEN` environment variable. - */ - authToken?: string; - - /** - * The base URL of your Sentry instance. Use this if you are using a self-hosted - * or Sentry instance other than sentry.io. - * - * This value can also be set via the `SENTRY_URL` environment variable. - * - * Defaults to https://sentry.io/, which is the correct value for SaaS customers. - */ - sentryUrl?: string; - - /** - * Headers added to every outgoing network request. - */ - headers?: Record; - - /** - * If set to true, internal plugin errors and performance data will be sent to Sentry. - * - * At Sentry we like to use Sentry ourselves to deliver faster and more stable products. - * We're very careful of what we're sending. We won't collect anything other than error - * and high-level performance data. We will never collect your code or any details of the - * projects in which you're using this plugin. - * - * Defaults to `true`. - */ - telemetry?: boolean; - - /** - * Suppresses all Sentry SDK build logs. - * - * Defaults to `false`. - */ - // TODO: Actually implement this for the non-plugin code. - silent?: boolean; - - /** - * Prints additional debug information about the SDK and uploading source maps when building the application. - * - * Defaults to `false`. - */ - // TODO: Actually implement this for the non-plugin code. - debug?: boolean; - - /** - * Options for source maps uploading. - */ - sourcemaps?: { - /** - * Disable any functionality related to source maps upload. - */ - disable?: boolean; - - /** - * A glob or an array of globs that specifies the build artifacts that should be uploaded to Sentry. - * - * If this option is not specified, the plugin will try to upload all JavaScript files and source map files that are created during build. - * - * The globbing patterns follow the implementation of the `glob` package. (https://www.npmjs.com/package/glob) - * - * Use the `debug` option to print information about which files end up being uploaded. - */ - assets?: string | string[]; - - /** - * A glob or an array of globs that specifies which build artifacts should not be uploaded to Sentry. - * - * Default: `[]` - * - * The globbing patterns follow the implementation of the `glob` package. (https://www.npmjs.com/package/glob) - * - * Use the `debug` option to print information about which files end up being uploaded. - */ - ignore?: string | string[]; - - /** - * Toggle whether generated source maps within your Next.js build folder should be automatically deleted after being uploaded to Sentry. - * - * Defaults to `false`. - */ - deleteSourcemapsAfterUpload?: boolean; - }; - - /** - * Options related to managing the Sentry releases for a build. - * - * More info: https://docs.sentry.io/product/releases/ - */ - release?: { - /** - * Unique identifier for the release you want to create. - * - * This value can also be specified via the `SENTRY_RELEASE` environment variable. - * - * Defaults to automatically detecting a value for your environment. - * This includes values for Cordova, Heroku, AWS CodeBuild, CircleCI, Xcode, and Gradle, and otherwise uses the git `HEAD`'s commit SHA. - * (the latter requires access to git CLI and for the root directory to be a valid repository) - * - * If you didn't provide a value and the plugin can't automatically detect one, no release will be created. - */ - name?: string; - - /** - * Whether the plugin should create a release on Sentry during the build. - * Note that a release may still appear in Sentry even if this is value is `false` because any Sentry event that has a release value attached will automatically create a release. - * (for example via the `inject` option) - * - * Defaults to `true`. - */ - create?: boolean; - - /** - * Whether the Sentry release should be automatically finalized (meaning an end timestamp is added) after the build ends. - * - * Defaults to `true`. - */ - finalize?: boolean; - - /** - * Unique identifier for the distribution, used to further segment your release. - * Usually your build number. - */ - dist?: string; - - /** - * Version control system remote name. - * - * This value can also be specified via the `SENTRY_VSC_REMOTE` environment variable. - * - * Defaults to 'origin'. - */ - vcsRemote?: string; - - /** - * Associates the release with its commits in Sentry. - */ - setCommits?: ( - | { - /** - * Automatically sets `commit` and `previousCommit`. Sets `commit` to `HEAD` - * and `previousCommit` as described in the option's documentation. - * - * If you set this to `true`, manually specified `commit` and `previousCommit` - * options will be overridden. It is best to not specify them at all if you - * set this option to `true`. - */ - auto: true; - - repo?: undefined; - commit?: undefined; - } - | { - auto?: false | undefined; - - /** - * The full repo name as defined in Sentry. - * - * Required if the `auto` option is not set to `true`. - */ - repo: string; - - /** - * The current (last) commit in the release. - * - * Required if the `auto` option is not set to `true`. - */ - commit: string; - } - ) & { - /** - * The commit before the beginning of this release (in other words, - * the last commit of the previous release). - * - * Defaults to the last commit of the previous release in Sentry. - * - * If there was no previous release, the last 10 commits will be used. - */ - previousCommit?: string; - - /** - * If the flag is to `true` and the previous release commit was not found - * in the repository, the plugin creates a release with the default commits - * count instead of failing the command. - * - * Defaults to `false`. - */ - ignoreMissing?: boolean; - - /** - * If this flag is set, the setCommits step will not fail and just exit - * silently if no new commits for a given release have been found. - * - * Defaults to `false`. - */ - ignoreEmpty?: boolean; - }; - - /** - * Adds deployment information to the release in Sentry. - */ - deploy?: { - /** - * Environment for this release. Values that make sense here would - * be `production` or `staging`. - */ - env: string; - - /** - * Deployment start time in Unix timestamp (in seconds) or ISO 8601 format. - */ - started?: number | string; - - /** - * Deployment finish time in Unix timestamp (in seconds) or ISO 8601 format. - */ - finished?: number | string; - - /** - * Deployment duration (in seconds). Can be used instead of started and finished. - */ - time?: number; - - /** - * Human readable name for the deployment. - */ - name?: string; - - /** - * URL that points to the deployment. - */ - url?: string; - }; - }; - - /** - * Options to configure various bundle size optimizations related to the Sentry SDK. - */ - bundleSizeOptimizations?: { - /** - * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) any debugging code within itself during the build. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * Setting this option to `true` will disable features like the SDK's `debug` option. - */ - excludeDebugStatements?: boolean; - - /** - * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code within itself that is related to tracing and performance monitoring. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * **Notice:** Do not enable this when you're using any performance monitoring-related SDK features (e.g. `Sentry.startTransaction()`). - */ - excludeTracing?: boolean; - - /** - * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code related to the SDK's Session Replay Shadow DOM recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * This option is safe to be used when you do not want to capture any Shadow DOM activity via Sentry Session Replay. - */ - excludeReplayShadowDom?: boolean; - - /** - * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code related to the SDK's Session Replay `iframe` recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * You can safely do this when you do not want to capture any `iframe` activity via Sentry Session Replay. - */ - excludeReplayIframe?: boolean; - - /** - * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code related to the SDK's Session Replay's Compression Web Worker. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * **Notice:** You should only use this option if you manually host a compression worker and configure it in your Sentry Session Replay integration config via the `workerUrl` option. - */ - excludeReplayWorker?: boolean; - }; - - /** - * Options related to react component name annotations. - * Disabled by default, unless a value is set for this option. - * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. - * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. - * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components - */ - reactComponentAnnotation?: { - /** - * Whether the component name annotate plugin should be enabled or not. - */ - enabled?: boolean; - }; - - /** - * Options to be passed directly to the Sentry Webpack Plugin (`@sentry/webpack-plugin`) that ships with the Sentry Next.js SDK. - * You can use this option to override any options the SDK passes to the webpack plugin. - * - * Please note that this option is unstable and may change in a breaking way in any release. - */ - unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions; - - /** - * Use `hidden-source-map` for webpack `devtool` option, which strips the `sourceMappingURL` from the bottom of built - * JS files. - */ - hideSourceMaps?: boolean; - - /** - * Include Next.js-internal code and code from dependencies when uploading source maps. - * - * Note: Enabling this option can lead to longer build times. - * Disabling this option will leave you without readable stacktraces for dependencies and Next.js-internal code. - * - * Defaults to `false`. - */ - // Enabling this option may upload a lot of source maps and since the sourcemap upload endpoint in Sentry is super - // slow we don't enable it by default so that we don't opaquely increase build times for users. - // TODO: Add an alias to this function called "uploadSourceMapsForDependencies" - widenClientFileUpload?: boolean; - - /** - * Automatically instrument Next.js data fetching methods and Next.js API routes with error and performance monitoring. - * Defaults to `true`. - */ - autoInstrumentServerFunctions?: boolean; - - /** - * Automatically instrument Next.js middleware with error and performance monitoring. Defaults to `true`. - */ - autoInstrumentMiddleware?: boolean; - - /** - * Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`. - */ - autoInstrumentAppDirectory?: boolean; - - /** - * Exclude certain serverside API routes or pages from being instrumented with Sentry. This option takes an array of - * strings or regular expressions. This options also affects pages in the `app` directory. - * - * NOTE: Pages should be specified as routes (`/animals` or `/api/animals/[animalType]/habitat`), not filepaths - * (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full, - * exact match. - */ - excludeServerRoutes?: Array; - - /** - * Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events - * from being sent. This option should be a path (for example: '/error-monitoring'). - * - * NOTE: This feature only works with Next.js 11+ - */ - tunnelRoute?: string; - - /** - * Tree shakes Sentry SDK logger statements from the bundle. - */ - disableLogger?: boolean; - - /** - * Automatically create cron monitors in Sentry for your Vercel Cron Jobs if configured via `vercel.json`. - * - * Defaults to `false`. - */ - automaticVercelMonitors?: boolean; -}; - -export type NextConfigFunction = ( - phase: string, - defaults: { defaultConfig: NextConfigObject }, -) => NextConfigObject | PromiseLike; - -/** - * Webpack config - */ - -// Note: The interface for `ignoreWarnings` is larger but we only need this. See https://webpack.js.org/configuration/other-options/#ignorewarnings -export type IgnoreWarningsOption = ( - | { module?: RegExp; message?: RegExp } - | (( - webpackError: { - module?: { - readableIdentifier: (requestShortener: unknown) => string; - }; - message: string; - }, - compilation: { - requestShortener: unknown; - }, - ) => boolean) -)[]; - -// The two possible formats for providing custom webpack config in `next.config.js` -export type WebpackConfigFunction = (config: WebpackConfigObject, options: BuildContext) => WebpackConfigObject; -export type WebpackConfigObject = { - devtool?: string; - plugins?: Array; - entry: WebpackEntryProperty; - output: { filename: string; path: string }; - target: string; - context: string; - ignoreWarnings?: IgnoreWarningsOption; - resolve?: { - modules?: string[]; - alias?: { [key: string]: string | boolean }; - }; - module?: { - rules: Array; - }; -} & { - // Other webpack options - [key: string]: unknown; -}; - -// A convenience type to save us from having to assert the existence of `module.rules` over and over -export type WebpackConfigObjectWithModuleRules = WebpackConfigObject & Required>; - -// Information about the current build environment -export type BuildContext = { - dev: boolean; - isServer: boolean; - buildId: string; - dir: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config: any; - webpack: { - version: string; - DefinePlugin: typeof DefinePlugin; - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - defaultLoaders: any; // needed for type tests (test:types) - totalPages: number; // needed for type tests (test:types) - nextRuntime?: 'nodejs' | 'edge'; // Added in Next.js 12+ -}; - -/** - * Webpack `entry` config - */ - -// For our purposes, the value for `entry` is either an object, or an async function which returns such an object -export type WebpackEntryProperty = EntryPropertyObject | EntryPropertyFunction; - -export type EntryPropertyObject = { - [key: string]: EntryPointValue; -}; - -export type EntryPropertyFunction = () => Promise; - -// Each value in that object is either a string representing a single entry point, an array of such strings, or an -// object containing either of those, along with other configuration options. In that third case, the entry point(s) are -// listed under the key `import`. -export type EntryPointValue = string | Array | EntryPointObject; -export type EntryPointObject = { import: string | Array }; - -/** - * Webpack `module.rules` entry - */ - -export type WebpackModuleRule = { - test?: string | RegExp | ((resourcePath: string) => boolean); - include?: Array | RegExp; - exclude?: (filepath: string) => boolean; - use?: ModuleRuleUseProperty | Array; - oneOf?: Array; -}; - -export type ModuleRuleUseProperty = { - loader?: string; - options?: Record; -}; - -/** - * Global with values we add when we inject code into people's pages, for use at runtime. - */ -export type EnhancedGlobal = typeof GLOBAL_OBJ & { - __rewriteFramesDistDir__?: string; - SENTRY_RELEASE?: { id: string }; - SENTRY_RELEASES?: { [key: string]: { id: string } }; -}; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts deleted file mode 100644 index 8fbc94b42195..000000000000 --- a/packages/nextjs/src/config/webpack.ts +++ /dev/null @@ -1,706 +0,0 @@ -/* eslint-disable complexity */ -/* eslint-disable max-lines */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { getSentryRelease } from '@sentry/node'; -import { arrayify, escapeStringForRegex, loadModule, logger } from '@sentry/utils'; -import * as chalk from 'chalk'; -import { sync as resolveSync } from 'resolve'; - -import type { VercelCronsConfig } from '../common/types'; -// Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our -// circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306. -import type { - BuildContext, - EntryPropertyObject, - IgnoreWarningsOption, - NextConfigObject, - SentryBuildOptions, - WebpackConfigFunction, - WebpackConfigObject, - WebpackConfigObjectWithModuleRules, - WebpackEntryProperty, -} from './types'; -import { getWebpackPluginOptions } from './webpackPluginOptions'; - -// Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain -// warnings 3 times, we keep track of them here. -let showedMissingGlobalErrorWarningMsg = false; - -/** - * Construct the function which will be used as the nextjs config's `webpack` value. - * - * Sets: - * - `devtool`, to ensure high-quality sourcemaps are generated - * - `entry`, to include user's sentry config files (where `Sentry.init` is called) in the build - * - `plugins`, to add SentryWebpackPlugin - * - * @param userNextConfig The user's existing nextjs config, as passed to `withSentryConfig` - * @param userSentryOptions The user's SentryWebpackPlugin config, as passed to `withSentryConfig` - * @returns The function to set as the nextjs config's `webpack` value - */ -export function constructWebpackConfigFunction( - userNextConfig: NextConfigObject = {}, - userSentryOptions: SentryBuildOptions = {}, -): WebpackConfigFunction { - // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether - // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that - // `incomingConfig` and `buildContext` are referred to as `config` and `options` in the nextjs docs. - return function newWebpackFunction( - incomingConfig: WebpackConfigObject, - buildContext: BuildContext, - ): WebpackConfigObject { - const { isServer, dev: isDev, dir: projectDir } = buildContext; - const runtime = isServer ? (buildContext.nextRuntime === 'edge' ? 'edge' : 'server') : 'client'; - - if (runtime !== 'client') { - warnAboutDeprecatedConfigFiles(projectDir, runtime); - } - - let rawNewConfig = { ...incomingConfig }; - - // if user has custom webpack config (which always takes the form of a function), run it so we have actual values to - // work with - if ('webpack' in userNextConfig && typeof userNextConfig.webpack === 'function') { - rawNewConfig = userNextConfig.webpack(rawNewConfig, buildContext); - } - - // This mutates `rawNewConfig` in place, but also returns it in order to switch its type to one in which - // `newConfig.module.rules` is required, so we don't have to keep asserting its existence - const newConfig = setUpModuleRules(rawNewConfig); - - // Add a loader which will inject code that sets global values - addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext); - - addOtelWarningIgnoreRule(newConfig); - - let pagesDirPath: string | undefined; - const maybePagesDirPath = path.join(projectDir, 'pages'); - const maybeSrcPagesDirPath = path.join(projectDir, 'src', 'pages'); - if (fs.existsSync(maybePagesDirPath) && fs.lstatSync(maybePagesDirPath).isDirectory()) { - pagesDirPath = maybePagesDirPath; - } else if (fs.existsSync(maybeSrcPagesDirPath) && fs.lstatSync(maybeSrcPagesDirPath).isDirectory()) { - pagesDirPath = maybeSrcPagesDirPath; - } - - let appDirPath: string | undefined; - const maybeAppDirPath = path.join(projectDir, 'app'); - const maybeSrcAppDirPath = path.join(projectDir, 'src', 'app'); - if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) { - appDirPath = maybeAppDirPath; - } else if (fs.existsSync(maybeSrcAppDirPath) && fs.lstatSync(maybeSrcAppDirPath).isDirectory()) { - appDirPath = maybeSrcAppDirPath; - } - - const apiRoutesPath = pagesDirPath ? path.join(pagesDirPath, 'api') : undefined; - - const middlewareLocationFolder = pagesDirPath - ? path.join(pagesDirPath, '..') - : appDirPath - ? path.join(appDirPath, '..') - : projectDir; - - // Default page extensions per https://github.com/vercel/next.js/blob/f1dbc9260d48c7995f6c52f8fbcc65f08e627992/packages/next/server/config-shared.ts#L161 - const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js']; - const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`); - const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|'); - - const staticWrappingLoaderOptions = { - appDir: appDirPath, - pagesDir: pagesDirPath, - pageExtensionRegex, - excludeServerRoutes: userSentryOptions.excludeServerRoutes, - nextjsRequestAsyncStorageModulePath: getRequestAsyncStorageModuleLocation( - projectDir, - rawNewConfig.resolve?.modules, - ), - }; - - const normalizeLoaderResourcePath = (resourcePath: string): string => { - // `resourcePath` may be an absolute path or a path relative to the context of the webpack config - let absoluteResourcePath: string; - if (path.isAbsolute(resourcePath)) { - absoluteResourcePath = resourcePath; - } else { - absoluteResourcePath = path.join(projectDir, resourcePath); - } - - return path.normalize(absoluteResourcePath); - }; - - const isPageResource = (resourcePath: string): boolean => { - const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); - return ( - pagesDirPath !== undefined && - normalizedAbsoluteResourcePath.startsWith(pagesDirPath + path.sep) && - !normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) && - dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext)) - ); - }; - - const isApiRouteResource = (resourcePath: string): boolean => { - const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); - return ( - normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) && - dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext)) - ); - }; - - const possibleMiddlewareLocations = pageExtensions.map(middlewareFileEnding => { - return path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`); - }); - const isMiddlewareResource = (resourcePath: string): boolean => { - const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); - return possibleMiddlewareLocations.includes(normalizedAbsoluteResourcePath); - }; - - const isServerComponentResource = (resourcePath: string): boolean => { - const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); - - // ".js, .jsx, or .tsx file extensions can be used for Pages" - // https://beta.nextjs.org/docs/routing/pages-and-layouts#pages:~:text=.js%2C%20.jsx%2C%20or%20.tsx%20file%20extensions%20can%20be%20used%20for%20Pages. - return ( - appDirPath !== undefined && - normalizedAbsoluteResourcePath.startsWith(appDirPath + path.sep) && - !!normalizedAbsoluteResourcePath.match( - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor - new RegExp(`[\\\\/](page|layout|loading|head|not-found)\\.(${pageExtensionRegex})$`), - ) - ); - }; - - const isRouteHandlerResource = (resourcePath: string): boolean => { - const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); - return ( - appDirPath !== undefined && - normalizedAbsoluteResourcePath.startsWith(appDirPath + path.sep) && - !!normalizedAbsoluteResourcePath.match( - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor - new RegExp(`[\\\\/]route\\.(${pageExtensionRegex})$`), - ) - ); - }; - - if (isServer && userSentryOptions.autoInstrumentServerFunctions !== false) { - // It is very important that we insert our loaders at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened. - - // Wrap pages - newConfig.module.rules.unshift({ - test: isPageResource, - use: [ - { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), - options: { - ...staticWrappingLoaderOptions, - wrappingTargetKind: 'page', - }, - }, - ], - }); - - let vercelCronsConfig: VercelCronsConfig = undefined; - try { - if (process.env.VERCEL && userSentryOptions.automaticVercelMonitors) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')).crons; - if (vercelCronsConfig) { - logger.info( - `${chalk.cyan( - 'info', - )} - Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the ${chalk.bold.cyan( - 'automaticVercelMonitors', - )} option to false in you Next.js config.`, - ); - } - } - } catch (e) { - if ((e as { code: string }).code === 'ENOENT') { - // noop if file does not exist - } else { - // log but noop - logger.error( - `${chalk.red( - 'error', - )} - Sentry failed to read vercel.json for automatic cron job monitoring instrumentation`, - e, - ); - } - } - - // Wrap api routes - newConfig.module.rules.unshift({ - test: isApiRouteResource, - use: [ - { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), - options: { - ...staticWrappingLoaderOptions, - vercelCronsConfig, - wrappingTargetKind: 'api-route', - }, - }, - ], - }); - - // Wrap middleware - if (userSentryOptions.autoInstrumentMiddleware ?? true) { - newConfig.module.rules.unshift({ - test: isMiddlewareResource, - use: [ - { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), - options: { - ...staticWrappingLoaderOptions, - wrappingTargetKind: 'middleware', - }, - }, - ], - }); - } - } - - if (isServer && userSentryOptions.autoInstrumentAppDirectory !== false) { - // Wrap server components - newConfig.module.rules.unshift({ - test: isServerComponentResource, - use: [ - { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), - options: { - ...staticWrappingLoaderOptions, - wrappingTargetKind: 'server-component', - }, - }, - ], - }); - - // Wrap route handlers - newConfig.module.rules.unshift({ - test: isRouteHandlerResource, - use: [ - { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), - options: { - ...staticWrappingLoaderOptions, - wrappingTargetKind: 'route-handler', - }, - }, - ], - }); - } - - if (appDirPath) { - const hasGlobalErrorFile = pageExtensions - .map(extension => `global-error.${extension}`) - .some( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - globalErrorFile => fs.existsSync(path.join(appDirPath!, globalErrorFile)), - ); - - if ( - !hasGlobalErrorFile && - !showedMissingGlobalErrorWarningMsg && - !process.env.SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING - ) { - // eslint-disable-next-line no-console - console.log( - `${chalk.yellow( - 'warn', - )} - It seems like you don't have a global error handler set up. It is recommended that you add a ${chalk.cyan( - 'global-error.js', - )} file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)`, - ); - showedMissingGlobalErrorWarningMsg = true; - } - } - - if (!isServer) { - // Tell webpack to inject the client config files (containing the client-side `Sentry.init()` call) into the appropriate output - // bundles. Store a separate reference to the original `entry` value to avoid an infinite loop. (If we don't do - // this, we'll have a statement of the form `x.y = () => f(x.y)`, where one of the things `f` does is call `x.y`. - // Since we're setting `x.y` to be a callback (which, by definition, won't run until some time later), by the time - // the function runs (causing `f` to run, causing `x.y` to run), `x.y` will point to the callback itself, rather - // than its original value. So calling it will call the callback which will call `f` which will call `x.y` which - // will call the callback which will call `f` which will call `x.y`... and on and on. Theoretically this could also - // be fixed by using `bind`, but this is way simpler.) - const origEntryProperty = newConfig.entry; - newConfig.entry = async () => addSentryToClientEntryProperty(origEntryProperty, buildContext); - } - - // We don't want to do any webpack plugin stuff OR any source maps stuff in dev mode. - // Symbolication for dev-mode errors is done elsewhere. - if (!isDev) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { sentryWebpackPlugin } = loadModule('@sentry/webpack-plugin') as any; - if (sentryWebpackPlugin) { - if (!userSentryOptions.sourcemaps?.disable) { - // `hidden-source-map` produces the same sourcemaps as `source-map`, but doesn't include the `sourceMappingURL` - // comment at the bottom. For folks who aren't publicly hosting their sourcemaps, this is helpful because then - // the browser won't look for them and throw errors into the console when it can't find them. Because this is a - // front-end-only problem, and because `sentry-cli` handles sourcemaps more reliably with the comment than - // without, the option to use `hidden-source-map` only applies to the client-side build. - newConfig.devtool = - isServer || userNextConfig.productionBrowserSourceMaps ? 'source-map' : 'hidden-source-map'; - } - - newConfig.plugins = newConfig.plugins || []; - const sentryWebpackPluginInstance = sentryWebpackPlugin( - getWebpackPluginOptions(buildContext, userSentryOptions), - ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - sentryWebpackPluginInstance._name = 'sentry-webpack-plugin'; // For tests and debugging. Serves no other purpose. - newConfig.plugins.push(sentryWebpackPluginInstance); - } - } - - if (userSentryOptions.disableLogger) { - newConfig.plugins = newConfig.plugins || []; - newConfig.plugins.push( - new buildContext.webpack.DefinePlugin({ - __SENTRY_DEBUG__: false, - }), - ); - } - - return newConfig; - }; -} - -/** - * Modify the webpack `entry` property so that the code in `sentry.client.config.js` is - * included in the the necessary bundles. - * - * @param currentEntryProperty The value of the property before Sentry code has been injected - * @param buildContext Object passed by nextjs containing metadata about the build - * @returns The value which the new `entry` property (which will be a function) will return (TODO: this should return - * the function, rather than the function's return value) - */ -async function addSentryToClientEntryProperty( - currentEntryProperty: WebpackEntryProperty, - buildContext: BuildContext, -): Promise { - // The `entry` entry in a webpack config can be a string, array of strings, object, or function. By default, nextjs - // sets it to an async function which returns the promise of an object of string arrays. Because we don't know whether - // someone else has come along before us and changed that, we need to check a few things along the way. The one thing - // we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function - // options. See https://webpack.js.org/configuration/entry-context/#entry. - - const { dir: projectDir, dev: isDevMode } = buildContext; - - const newEntryProperty = - typeof currentEntryProperty === 'function' ? await currentEntryProperty() : { ...currentEntryProperty }; - - const clientSentryConfigFileName = getClientSentryConfigFile(projectDir); - - // we need to turn the filename into a path so webpack can find it - const filesToInject = clientSentryConfigFileName ? [`./${clientSentryConfigFileName}`] : []; - - // inject into all entry points which might contain user's code - for (const entryPointName in newEntryProperty) { - if ( - entryPointName === 'pages/_app' || - // entrypoint for `/app` pages - entryPointName === 'main-app' - ) { - addFilesToWebpackEntryPoint(newEntryProperty, entryPointName, filesToInject, isDevMode); - } - } - - return newEntryProperty; -} - -/** - * Searches for old `sentry.(server|edge).config.ts` files and Next.js instrumentation hooks and warns if there are "old" - * config files and no signs of them inside the instrumentation hook. - * - * @param projectDir The root directory of the project, where config files would be located - * @param platform Either "server" or "edge", so that we know which file to look for - */ -function warnAboutDeprecatedConfigFiles(projectDir: string, platform: 'server' | 'edge'): void { - const hasInstrumentationHookWithIndicationsOfSentry = [ - ['src', 'instrumentation.ts'], - ['src', 'instrumentation.js'], - ['instrumentation.ts'], - ['instrumentation.js'], - ].some(potentialInstrumentationHookPathSegments => { - try { - const instrumentationHookContent = fs.readFileSync( - path.resolve(projectDir, ...potentialInstrumentationHookPathSegments), - { encoding: 'utf-8' }, - ); - - return ( - instrumentationHookContent.includes('@sentry/') || - instrumentationHookContent.match(/sentry\.(server|edge)\.config(\.(ts|js))?/) - ); - } catch (e) { - return false; - } - }); - - if (hasInstrumentationHookWithIndicationsOfSentry) { - return; - } - - for (const filename of [`sentry.${platform}.config.ts`, `sentry.${platform}.config.js`]) { - if (fs.existsSync(path.resolve(projectDir, filename))) { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] It appears you've configured a \`${filename}\` file. Please ensure to put this file's content into the \`register()\` function of a Next.js instrumentation hook instead. To ensure correct functionality of the SDK, \`Sentry.init\` must be called inside \`instrumentation.ts\`. Learn more about setting up an instrumentation hook in Next.js: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation. You can safely delete the \`${filename}\` file afterward.`, - ); - } - } -} - -/** - * Searches for a `sentry.client.config.ts|js` file and returns its file name if it finds one. (ts being prioritized) - * - * @param projectDir The root directory of the project, where config files would be located - */ -export function getClientSentryConfigFile(projectDir: string): string | void { - const possibilities = ['sentry.client.config.ts', 'sentry.client.config.js']; - - for (const filename of possibilities) { - if (fs.existsSync(path.resolve(projectDir, filename))) { - return filename; - } - } -} - -/** - * Add files to a specific element of the given `entry` webpack config property. - * - * @param entryProperty The existing `entry` config object - * @param entryPointName The key where the file should be injected - * @param filesToInsert An array of paths to the injected files - */ -function addFilesToWebpackEntryPoint( - entryProperty: EntryPropertyObject, - entryPointName: string, - filesToInsert: string[], - isDevMode: boolean, -): void { - // BIG FAT NOTE: Order of insertion seems to matter here. If we insert the new files before the `currentEntrypoint`s, - // the Next.js dev server breaks. Because we generally still want the SDK to be initialized as early as possible we - // still keep it at the start of the entrypoints if we are not in dev mode. - - // can be a string, array of strings, or object whose `import` property is one of those two - const currentEntryPoint = entryProperty[entryPointName]; - let newEntryPoint = currentEntryPoint; - - if (typeof currentEntryPoint === 'string' || Array.isArray(currentEntryPoint)) { - newEntryPoint = arrayify(currentEntryPoint); - if (newEntryPoint.some(entry => filesToInsert.includes(entry))) { - return; - } - - if (isDevMode) { - // Inserting at beginning breaks dev mode so we insert at the end - newEntryPoint.push(...filesToInsert); - } else { - // In other modes we insert at the beginning so that the SDK initializes as early as possible - newEntryPoint.unshift(...filesToInsert); - } - } - // descriptor object (webpack 5+) - else if (typeof currentEntryPoint === 'object' && 'import' in currentEntryPoint) { - const currentImportValue = currentEntryPoint.import; - const newImportValue = arrayify(currentImportValue); - if (newImportValue.some(entry => filesToInsert.includes(entry))) { - return; - } - - if (isDevMode) { - // Inserting at beginning breaks dev mode so we insert at the end - newImportValue.push(...filesToInsert); - } else { - // In other modes we insert at the beginning so that the SDK initializes as early as possible - newImportValue.unshift(...filesToInsert); - } - - newEntryPoint = { - ...currentEntryPoint, - import: newImportValue, - }; - } - // malformed entry point (use `console.error` rather than `logger.error` because it will always be printed, regardless - // of SDK settings) - else { - // eslint-disable-next-line no-console - console.error( - 'Sentry Logger [Error]:', - `Could not inject SDK initialization code into entry point ${entryPointName}, as its current value is not in a recognized format.\n`, - 'Expected: string | Array | { [key:string]: any, import: string | Array }\n', - `Got: ${currentEntryPoint}`, - ); - } - - if (newEntryPoint) { - entryProperty[entryPointName] = newEntryPoint; - } -} - -/** - * Ensure that `newConfig.module.rules` exists. Modifies the given config in place but also returns it in order to - * change its type. - * - * @param newConfig A webpack config object which may or may not contain `module` and `module.rules` - * @returns The same object, with an empty `module.rules` array added if necessary - */ -function setUpModuleRules(newConfig: WebpackConfigObject): WebpackConfigObjectWithModuleRules { - newConfig.module = { - ...newConfig.module, - rules: [...(newConfig.module?.rules || [])], - }; - // Surprising that we have to assert the type here, since we've demonstrably guaranteed the existence of - // `newConfig.module.rules` just above, but ¯\_(ツ)_/¯ - return newConfig as WebpackConfigObjectWithModuleRules; -} - -/** - * Adds loaders to inject values on the global object based on user configuration. - */ -function addValueInjectionLoader( - newConfig: WebpackConfigObjectWithModuleRules, - userNextConfig: NextConfigObject, - userSentryOptions: SentryBuildOptions, - buildContext: BuildContext, -): void { - const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; - - const isomorphicValues = { - // `rewritesTunnel` set by the user in Next.js config - __sentryRewritesTunnelPath__: - userSentryOptions.tunnelRoute !== undefined && userNextConfig.output !== 'export' - ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` - : undefined, - - // The webpack plugin's release injection breaks the `app` directory so we inject the release manually here instead. - // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode - SENTRY_RELEASE: buildContext.dev - ? undefined - : { id: userSentryOptions.release?.name ?? getSentryRelease(buildContext.buildId) }, - __sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, - }; - - const serverValues = { - ...isomorphicValues, - // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape - // characters) - __rewriteFramesDistDir__: userNextConfig.distDir?.replace(/\\/g, '\\\\') || '.next', - }; - - const clientValues = { - ...isomorphicValues, - // Get the path part of `assetPrefix`, minus any trailing slash. (We use a placeholder for the origin if - // `assetPrefix` doesn't include one. Since we only care about the path, it doesn't matter what it is.) - __rewriteFramesAssetPrefixPath__: assetPrefix - ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') - : '', - }; - - if (buildContext.isServer) { - newConfig.module.rules.push({ - // TODO: Find a more bulletproof way of matching. For now this is fine and doesn't hurt anyone. It merely sets some globals. - test: /(src[\\/])?instrumentation.(js|ts)/, - use: [ - { - loader: path.resolve(__dirname, 'loaders/valueInjectionLoader.js'), - options: { - values: serverValues, - }, - }, - ], - }); - } else { - newConfig.module.rules.push({ - test: /sentry\.client\.config\.(jsx?|tsx?)/, - use: [ - { - loader: path.resolve(__dirname, 'loaders/valueInjectionLoader.js'), - options: { - values: clientValues, - }, - }, - ], - }); - } -} - -function resolveNextPackageDirFromDirectory(basedir: string): string | undefined { - try { - return path.dirname(resolveSync('next/package.json', { basedir })); - } catch { - // Should not happen in theory - return undefined; - } -} - -const POTENTIAL_REQUEST_ASYNC_STORAGE_LOCATIONS = [ - // Original location of RequestAsyncStorage - // https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts - 'next/dist/client/components/request-async-storage.js', - // Introduced in Next.js 13.4.20 - // https://github.com/vercel/next.js/blob/e1bc270830f2fc2df3542d4ef4c61b916c802df3/packages/next/src/client/components/request-async-storage.external.ts - 'next/dist/client/components/request-async-storage.external.js', -]; - -function getRequestAsyncStorageModuleLocation( - webpackContextDir: string, - webpackResolvableModuleLocations: string[] | undefined, -): string | undefined { - if (webpackResolvableModuleLocations === undefined) { - return undefined; - } - - const absoluteWebpackResolvableModuleLocations = webpackResolvableModuleLocations.map(loc => - path.resolve(webpackContextDir, loc), - ); - - for (const webpackResolvableLocation of absoluteWebpackResolvableModuleLocations) { - const nextPackageDir = resolveNextPackageDirFromDirectory(webpackResolvableLocation); - if (nextPackageDir) { - const asyncLocalStorageLocation = POTENTIAL_REQUEST_ASYNC_STORAGE_LOCATIONS.find(loc => - fs.existsSync(path.join(nextPackageDir, '..', loc)), - ); - if (asyncLocalStorageLocation) { - return asyncLocalStorageLocation; - } - } - } - - return undefined; -} - -function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules): void { - const ignoreRules = [ - // Inspired by @matmannion: https://github.com/getsentry/sentry-javascript/issues/12077#issuecomment-2180307072 - (warning, compilation) => { - // This is wapped in try-catch because we are vendoring types for this hook and we can't be 100% sure that we are accessing API that is there - try { - if (!warning.module) { - return false; - } - - const isDependencyThatMayRaiseCriticalDependencyMessage = - /@opentelemetry\/instrumentation/.test(warning.module.readableIdentifier(compilation.requestShortener)) || - /@prisma\/instrumentation/.test(warning.module.readableIdentifier(compilation.requestShortener)); - const isCriticalDependencyMessage = /Critical dependency/.test(warning.message); - - return isDependencyThatMayRaiseCriticalDependencyMessage && isCriticalDependencyMessage; - } catch { - return false; - } - }, - // We provide these objects in addition to the hook above to provide redundancy in case the hook fails. - { module: /@opentelemetry\/instrumentation/, message: /Critical dependency/ }, - { module: /@prisma\/instrumentation/, message: /Critical dependency/ }, - ] satisfies IgnoreWarningsOption; - - if (newConfig.ignoreWarnings === undefined) { - newConfig.ignoreWarnings = ignoreRules; - } else if (Array.isArray(newConfig.ignoreWarnings)) { - newConfig.ignoreWarnings.push(...ignoreRules); - } -} diff --git a/packages/nextjs/src/config/webpackPluginOptions.ts b/packages/nextjs/src/config/webpackPluginOptions.ts deleted file mode 100644 index 7b183047896a..000000000000 --- a/packages/nextjs/src/config/webpackPluginOptions.ts +++ /dev/null @@ -1,117 +0,0 @@ -import * as path from 'path'; -import { getSentryRelease } from '@sentry/node'; -import type { SentryWebpackPluginOptions } from '@sentry/webpack-plugin'; -import type { BuildContext, NextConfigObject, SentryBuildOptions } from './types'; - -/** - * Combine default and user-provided SentryWebpackPlugin options, accounting for whether we're building server files or - * client files. - */ -export function getWebpackPluginOptions( - buildContext: BuildContext, - sentryBuildOptions: SentryBuildOptions, -): SentryWebpackPluginOptions { - const { buildId, isServer, config: userNextConfig, dir, nextRuntime } = buildContext; - - const prefixInsert = !isServer ? 'Client' : nextRuntime === 'edge' ? 'Edge' : 'Node.js'; - - // We need to convert paths to posix because Glob patterns use `\` to escape - // glob characters. This clashes with Windows path separators. - // See: https://www.npmjs.com/package/glob - const projectDir = dir.replace(/\\/g, '/'); - // `.next` is the default directory - const distDir = (userNextConfig as NextConfigObject).distDir?.replace(/\\/g, '/') ?? '.next'; - const distDirAbsPath = path.posix.join(projectDir, distDir); - - let sourcemapUploadAssets: string[] = []; - const sourcemapUploadIgnore: string[] = []; - - if (isServer) { - sourcemapUploadAssets.push( - path.posix.join(distDirAbsPath, 'server', '**'), // This is normally where Next.js outputs things - path.posix.join(distDirAbsPath, 'serverless', '**'), // This was the output location for serverless Next.js - ); - } else { - if (sentryBuildOptions.widenClientFileUpload) { - sourcemapUploadAssets.push(path.posix.join(distDirAbsPath, 'static', 'chunks', '**')); - } else { - sourcemapUploadAssets.push( - path.posix.join(distDirAbsPath, 'static', 'chunks', 'pages', '**'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'app', '**'), - ); - } - - // TODO: We should think about uploading these when `widenClientFileUpload` is `true`. They may be useful in some situations. - sourcemapUploadIgnore.push( - path.posix.join(distDirAbsPath, 'static', 'chunks', 'framework-*'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'framework.*'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'main-*'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'polyfills-*'), - path.posix.join(distDirAbsPath, 'static', 'chunks', 'webpack-*'), - ); - } - - if (sentryBuildOptions.sourcemaps?.disable) { - sourcemapUploadAssets = []; - } - - return { - authToken: sentryBuildOptions.authToken, - headers: sentryBuildOptions.headers, - org: sentryBuildOptions.org, - project: sentryBuildOptions.project, - telemetry: sentryBuildOptions.telemetry, - debug: sentryBuildOptions.debug, - reactComponentAnnotation: { - ...sentryBuildOptions.reactComponentAnnotation, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.reactComponentAnnotation, - }, - silent: sentryBuildOptions.silent, - url: sentryBuildOptions.sentryUrl, - sourcemaps: { - rewriteSources(source) { - if (source.startsWith('webpack://_N_E/')) { - return source.replace('webpack://_N_E/', ''); - } else if (source.startsWith('webpack://')) { - return source.replace('webpack://', ''); - } else { - return source; - } - }, - assets: sentryBuildOptions.sourcemaps?.assets ?? sourcemapUploadAssets, - ignore: sentryBuildOptions.sourcemaps?.ignore ?? sourcemapUploadIgnore, - filesToDeleteAfterUpload: sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload - ? [ - // We only care to delete client bundle source maps because they would be the ones being served. - // Removing the server source maps crashes Vercel builds for (thus far) unknown reasons: - // https://github.com/getsentry/sentry-javascript/issues/13099 - path.posix.join(distDirAbsPath, 'static', '**', '*.js.map'), - path.posix.join(distDirAbsPath, 'static', '**', '*.mjs.map'), - path.posix.join(distDirAbsPath, 'static', '**', '*.cjs.map'), - ] - : undefined, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.sourcemaps, - }, - release: { - inject: false, // The webpack plugin's release injection breaks the `app` directory - we inject the release manually with the value injection loader instead. - name: sentryBuildOptions.release?.name ?? getSentryRelease(buildId), - create: sentryBuildOptions.release?.create, - finalize: sentryBuildOptions.release?.finalize, - dist: sentryBuildOptions.release?.dist, - vcsRemote: sentryBuildOptions.release?.vcsRemote, - setCommits: sentryBuildOptions.release?.setCommits, - deploy: sentryBuildOptions.release?.deploy, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions?.release, - }, - bundleSizeOptimizations: { - ...sentryBuildOptions.bundleSizeOptimizations, - }, - _metaOptions: { - loggerPrefixOverride: `[@sentry/nextjs - ${prefixInsert}]`, - telemetry: { - metaFramework: 'nextjs', - }, - }, - ...sentryBuildOptions.unstable_sentryWebpackPluginOptions, - }; -} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts deleted file mode 100644 index 4f5205fecfcb..000000000000 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ /dev/null @@ -1,278 +0,0 @@ -/* eslint-disable complexity */ -import { isThenable, parseSemver } from '@sentry/utils'; - -import * as fs from 'fs'; -import { sync as resolveSync } from 'resolve'; -import type { - ExportedNextConfig as NextConfig, - NextConfigFunction, - NextConfigObject, - SentryBuildOptions, -} from './types'; -import { constructWebpackConfigFunction } from './webpack'; - -let showedExportModeTunnelWarning = false; - -/** - * Modifies the passed in Next.js configuration with automatic build-time instrumentation and source map upload. - * - * @param nextConfig A Next.js configuration object, as usually exported in `next.config.js` or `next.config.mjs`. - * @param sentryBuildOptions Additional options to configure instrumentation and - * @returns The modified config to be exported - */ -export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C { - const castNextConfig = (nextConfig as NextConfig) || {}; - if (typeof castNextConfig === 'function') { - return function (this: unknown, ...webpackConfigFunctionArgs: unknown[]): ReturnType { - const maybePromiseNextConfig: ReturnType = castNextConfig.apply( - this, - webpackConfigFunctionArgs, - ); - - if (isThenable(maybePromiseNextConfig)) { - return maybePromiseNextConfig.then(promiseResultNextConfig => { - return getFinalConfigObject(promiseResultNextConfig, sentryBuildOptions); - }); - } - - return getFinalConfigObject(maybePromiseNextConfig, sentryBuildOptions); - } as C; - } else { - return getFinalConfigObject(castNextConfig, sentryBuildOptions) as C; - } -} - -// Modify the materialized object form of the user's next config by deleting the `sentry` property and wrapping the -// `webpack` property -function getFinalConfigObject( - incomingUserNextConfigObject: NextConfigObject, - userSentryOptions: SentryBuildOptions, -): NextConfigObject { - // TODO(v9): Remove this check for the Sentry property - if ('sentry' in incomingUserNextConfigObject) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] Setting a `sentry` property on the Next.js config object as a means of configuration is no longer supported. Please use the `sentryBuildOptions` argument of of the `withSentryConfig()` function instead.', - ); - - // Next 12.2.3+ warns about non-canonical properties on `userNextConfig`. - delete incomingUserNextConfigObject.sentry; - } - - if (userSentryOptions?.tunnelRoute) { - if (incomingUserNextConfigObject.output === 'export') { - if (!showedExportModeTunnelWarning) { - showedExportModeTunnelWarning = true; - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] The Sentry Next.js SDK `tunnelRoute` option will not work in combination with Next.js static exports. The `tunnelRoute` option uses serverside features that cannot be accessed in export mode. If you still want to tunnel Sentry events, set up your own tunnel: https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option', - ); - } - } else { - setUpTunnelRewriteRules(incomingUserNextConfigObject, userSentryOptions.tunnelRoute); - } - } - - const nextJsVersion = getNextjsVersion(); - - // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). - // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. - if (nextJsVersion) { - const { major, minor } = parseSemver(nextJsVersion); - if (major !== undefined && minor !== undefined && (major >= 15 || (major === 14 && minor >= 3))) { - incomingUserNextConfigObject.experimental = incomingUserNextConfigObject.experimental || {}; - incomingUserNextConfigObject.experimental.clientTraceMetadata = [ - 'baggage', - 'sentry-trace', - ...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []), - ]; - } - } else { - // eslint-disable-next-line no-console - console.log( - "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage']` to your Next.js config to enable pageload tracing for App Router.", - ); - } - - // From Next.js version (15.0.0-canary.124) onwards, Next.js does no longer require the `experimental.instrumentationHook` option and will - // print a warning when it is set, so we need to conditionally provide it for lower versions. - if (nextJsVersion) { - const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); - const isFullySupportedRelease = - major !== undefined && - minor !== undefined && - patch !== undefined && - major >= 15 && - ((minor === 0 && patch === 0 && prerelease === undefined) || minor > 0 || patch > 0); - const isSupportedV15Rc = - major !== undefined && - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('rc.') && - parseInt(prerelease.split('.')[1] || '', 10) > 0; - const isSupportedCanary = - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('canary.') && - parseInt(prerelease.split('.')[1] || '', 10) >= 124; - - if (!isFullySupportedRelease && !isSupportedV15Rc && !isSupportedCanary) { - if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', - ); - } - incomingUserNextConfigObject.experimental = { - instrumentationHook: true, - ...incomingUserNextConfigObject.experimental, - }; - } - } else { - // If we cannot detect a Next.js version for whatever reason, the sensible default is to set the `experimental.instrumentationHook`, even though it may create a warning. - if ( - incomingUserNextConfigObject.experimental && - 'instrumentationHook' in incomingUserNextConfigObject.experimental - ) { - if (incomingUserNextConfigObject.experimental.instrumentationHook === false) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You set `experimental.instrumentationHook` to `false`. If you are using Next.js version 15 or greater, you can remove that option. If you are using Next.js version 14 or lower, you need to set `experimental.instrumentationHook` in your `next.config.(js|mjs)` to `true` for the SDK to be properly initialized in combination with `instrumentation.(js|ts)`.', - ); - } - } else { - // eslint-disable-next-line no-console - console.log( - "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, Next.js will probably show you a warning about the `experimental.instrumentationHook` being set. To silence Next.js' warning, explicitly set the `experimental.instrumentationHook` option in your `next.config.(js|mjs|ts)` to `undefined`. If you are on Next.js version 14 or lower, you can silence this particular warning by explicitly setting the `experimental.instrumentationHook` option in your `next.config.(js|mjs)` to `true`.", - ); - incomingUserNextConfigObject.experimental = { - instrumentationHook: true, - ...incomingUserNextConfigObject.experimental, - }; - } - } - - if (process.env.TURBOPACK && !process.env.SENTRY_SUPPRESS_TURBOPACK_WARNING) { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] WARNING: You are using the Sentry SDK with \`next ${ - process.env.NODE_ENV === 'development' ? 'dev' : 'build' - } --turbo\`. The Sentry SDK doesn't yet fully support Turbopack. The SDK will not be loaded in the browser, and serverside instrumentation will be inaccurate or incomplete. ${ - process.env.NODE_ENV === 'development' ? 'Production builds without `--turbo` will still fully work. ' : '' - }If you are just trying out Sentry or attempting to configure the SDK, we recommend temporarily removing the \`--turbo\` flag while you are developing locally. Follow this issue for progress on Sentry + Turbopack: https://github.com/getsentry/sentry-javascript/issues/8105. (You can suppress this warning by setting SENTRY_SUPPRESS_TURBOPACK_WARNING=1 as environment variable)`, - ); - } - - return { - ...incomingUserNextConfigObject, - webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions), - }; -} - -/** - * Injects rewrite rules into the Next.js config provided by the user to tunnel - * requests from the `tunnelPath` to Sentry. - * - * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. - */ -function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { - const originalRewrites = userNextConfig.rewrites; - - // This function doesn't take any arguments at the time of writing but we future-proof - // here in case Next.js ever decides to pass some - userNextConfig.rewrites = async (...args: unknown[]) => { - const tunnelRouteRewrite = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - ], - destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', - }; - - const tunnelRouteRewriteWithRegion = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]?r=[region]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'r', // short for region - we keep it short so matching is harder for ad-blockers - value: '(?[a-z]{2})', - }, - ], - destination: 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0', - }; - - // Order of these is important, they get applied first to last. - const newRewrites = [tunnelRouteRewriteWithRegion, tunnelRouteRewrite]; - - if (typeof originalRewrites !== 'function') { - return newRewrites; - } - - // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it - const originalRewritesResult = await originalRewrites(...args); - - if (Array.isArray(originalRewritesResult)) { - return [...newRewrites, ...originalRewritesResult]; - } else { - return { - ...originalRewritesResult, - beforeFiles: [...newRewrites, ...(originalRewritesResult.beforeFiles || [])], - }; - } - }; -} - -function getNextjsVersion(): string | undefined { - const nextjsPackageJsonPath = resolveNextjsPackageJson(); - if (nextjsPackageJsonPath) { - try { - const nextjsPackageJson: { version: string } = JSON.parse( - fs.readFileSync(nextjsPackageJsonPath, { encoding: 'utf-8' }), - ); - return nextjsPackageJson.version; - } catch { - // noop - } - } - - return undefined; -} - -function resolveNextjsPackageJson(): string | undefined { - try { - return resolveSync('next/package.json', { basedir: process.cwd() }); - } catch { - return undefined; - } -} diff --git a/packages/nextjs/src/edge/distDirRewriteFramesIntegration.ts b/packages/nextjs/src/edge/distDirRewriteFramesIntegration.ts deleted file mode 100644 index d2e1b519c29b..000000000000 --- a/packages/nextjs/src/edge/distDirRewriteFramesIntegration.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineIntegration, rewriteFramesIntegration } from '@sentry/core'; -import { escapeStringForRegex } from '@sentry/utils'; - -export const distDirRewriteFramesIntegration = defineIntegration(({ distDirName }: { distDirName: string }) => { - const distDirAbsPath = distDirName.replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one - - // Normally we would use `path.resolve` to obtain the absolute path we will strip from the stack frame to align with - // the uploaded artifacts, however we don't have access to that API in edge so we need to be a bit more lax. - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped - const SOURCEMAP_FILENAME_REGEX = new RegExp(`.*${escapeStringForRegex(distDirAbsPath)}`); - - const rewriteFramesIntegrationInstance = rewriteFramesIntegration({ - iteratee: frame => { - frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); - return frame; - }, - }); - - return { - ...rewriteFramesIntegrationInstance, - name: 'DistDirRewriteFrames', - }; -}); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts deleted file mode 100644 index 7034873f665e..000000000000 --- a/packages/nextjs/src/edge/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { applySdkMetadata, registerSpanErrorInstrumentation } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/utils'; -import type { VercelEdgeOptions } from '@sentry/vercel-edge'; -import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; - -import { isBuild } from '../common/utils/isBuild'; -import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; - -export type EdgeOptions = VercelEdgeOptions; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __rewriteFramesDistDir__?: string; -}; - -/** Inits the Sentry NextJS SDK on the Edge Runtime. */ -export function init(options: VercelEdgeOptions = {}): void { - registerSpanErrorInstrumentation(); - - if (isBuild()) { - return; - } - - const customDefaultIntegrations = getDefaultIntegrations(options); - - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; - - if (distDirName) { - customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName })); - } - - const opts = { - defaultIntegrations: customDefaultIntegrations, - ...options, - }; - - applySdkMetadata(opts, 'nextjs'); - - vercelEdgeInit(opts); -} - -/** - * Just a passthrough in case this is imported from the client. - */ -export function withSentryConfig(exportedUserNextConfig: T): T { - return exportedUserNextConfig; -} - -export * from '@sentry/vercel-edge'; - -export * from '../common'; - -export { wrapApiHandlerWithSentry } from './wrapApiHandlerWithSentry'; diff --git a/packages/nextjs/src/edge/rewriteFramesIntegration.ts b/packages/nextjs/src/edge/rewriteFramesIntegration.ts deleted file mode 100644 index 84d5a41b0922..000000000000 --- a/packages/nextjs/src/edge/rewriteFramesIntegration.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineIntegration, rewriteFramesIntegration as originalRewriteFramesIntegration } from '@sentry/core'; -import type { IntegrationFn, StackFrame } from '@sentry/types'; -import { GLOBAL_OBJ, escapeStringForRegex } from '@sentry/utils'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __rewriteFramesDistDir__?: string; -}; - -type StackFrameIteratee = (frame: StackFrame) => StackFrame; -interface RewriteFramesOptions { - root?: string; - prefix?: string; - iteratee?: StackFrameIteratee; -} - -export const customRewriteFramesIntegration = ((options?: RewriteFramesOptions) => { - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; - - if (distDirName) { - const distDirAbsPath = distDirName.replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one - - // Normally we would use `path.resolve` to obtain the absolute path we will strip from the stack frame to align with - // the uploaded artifacts, however we don't have access to that API in edge so we need to be a bit more lax. - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped - const SOURCEMAP_FILENAME_REGEX = new RegExp(`.*${escapeStringForRegex(distDirAbsPath)}`); - - return originalRewriteFramesIntegration({ - iteratee: frame => { - frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); - return frame; - }, - ...options, - }); - } - - // Do nothing if we can't find a distDirName - return { - name: 'RewriteFrames', - }; -}) satisfies IntegrationFn; - -export const rewriteFramesIntegration = defineIntegration(customRewriteFramesIntegration); diff --git a/packages/nextjs/src/edge/types.ts b/packages/nextjs/src/edge/types.ts deleted file mode 100644 index 71f96ec1946b..000000000000 --- a/packages/nextjs/src/edge/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -// We cannot make any assumptions about what users define as their handler except maybe that it is a function -export interface EdgeRouteHandler { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (...args: any[]): any; -} diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts deleted file mode 100644 index e5191ea27dbe..000000000000 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { withEdgeWrapping } from '../common/utils/edgeWrapperUtils'; -import type { EdgeRouteHandler } from './types'; - -/** - * Wraps a Next.js edge route handler with Sentry error and performance instrumentation. - */ -export function wrapApiHandlerWithSentry( - handler: H, - parameterizedRoute: string, -): (...params: Parameters) => Promise> { - return new Proxy(handler, { - apply: (wrappingTarget, thisArg, args: Parameters) => { - const req = args[0]; - - const wrappedHandler = withEdgeWrapping(wrappingTarget, { - spanDescription: !(req instanceof Request) - ? `handler (${parameterizedRoute})` - : `${req.method} ${parameterizedRoute}`, - spanOp: 'http.server', - mechanismFunctionName: 'wrapApiHandlerWithSentry', - }); - - return wrappedHandler.apply(thisArg, args); - }, - }); -} diff --git a/packages/nextjs/src/index.client.ts b/packages/nextjs/src/index.client.ts deleted file mode 100644 index 4836c98e819b..000000000000 --- a/packages/nextjs/src/index.client.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './client'; - -// This file is the main entrypoint for non-Next.js build pipelines that use -// the package.json's "browser" field or the Edge runtime (Edge API routes and middleware) diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts deleted file mode 100644 index 133b6ecf1da0..000000000000 --- a/packages/nextjs/src/index.server.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './config'; -export * from './server'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts deleted file mode 100644 index a272990162b3..000000000000 --- a/packages/nextjs/src/index.types.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -// We export everything from both the client part of the SDK and from the server part. Some of the exports collide, -// which is not allowed, unless we redifine the colliding exports in this file - which we do below. -export * from './config'; -export * from './client'; -export * from './server'; -export * from './edge'; - -import type { Integration, Options, StackParser } from '@sentry/types'; - -import type * as clientSdk from './client'; -import type { ServerComponentContext, VercelCronsConfig } from './common/types'; -import type * as edgeSdk from './edge'; -import type * as serverSdk from './server'; - -/** Initializes Sentry Next.js SDK */ -export declare function init( - options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions | edgeSdk.EdgeOptions, -): void; - -export declare const getClient: typeof clientSdk.getClient; -export declare const getRootSpan: typeof serverSdk.getRootSpan; -export declare const continueTrace: typeof clientSdk.continueTrace; - -export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; -export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; - -export declare const getDefaultIntegrations: (options: Options) => Integration[]; -export declare const defaultStackParser: StackParser; - -export declare function getSentryRelease(fallback?: string): string | undefined; - -export declare const ErrorBoundary: typeof clientSdk.ErrorBoundary; -export declare const createReduxEnhancer: typeof clientSdk.createReduxEnhancer; -export declare const showReportDialog: typeof clientSdk.showReportDialog; -export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; - -export declare const metrics: typeof clientSdk.metrics & typeof serverSdk.metrics; - -export { withSentryConfig } from './config'; - -/** - * Wraps a Next.js Pages Router API route with Sentry error and performance instrumentation. - * - * NOTICE: This wrapper is for Pages Router API routes. If you are looking to wrap App Router API routes use `wrapRouteHandlerWithSentry` instead. - * - * @param handler The handler exported from the API route file. - * @param parameterizedRoute The page's parameterized route. - * @returns The wrapped handler. - */ -export declare function wrapApiHandlerWithSentry any>( - handler: APIHandler, - parameterizedRoute: string, -): ( - ...args: Parameters -) => ReturnType extends Promise ? ReturnType : Promise>; - -/** - * Wraps a `getInitialProps` function with Sentry error and performance instrumentation. - * - * @param getInitialProps The `getInitialProps` function - * @returns A wrapped version of the function - */ -export declare function wrapGetInitialPropsWithSentry any>( - getInitialProps: F, -): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise>; - -/** - * Wraps a `getInitialProps` function of a custom `_app` page with Sentry error and performance instrumentation. - * - * @param getInitialProps The `getInitialProps` function - * @returns A wrapped version of the function - */ -export declare function wrapAppGetInitialPropsWithSentry any>( - getInitialProps: F, -): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise>; - -/** - * Wraps a `getInitialProps` function of a custom `_document` page with Sentry error and performance instrumentation. - * - * @param getInitialProps The `getInitialProps` function - * @returns A wrapped version of the function - */ -export declare function wrapDocumentGetInitialPropsWithSentry any>( - getInitialProps: F, -): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise>; - -/** - * Wraps a `getInitialProps` function of a custom `_error` page with Sentry error and performance instrumentation. - * - * @param getInitialProps The `getInitialProps` function - * @returns A wrapped version of the function - */ -export declare function wrapErrorGetInitialPropsWithSentry any>( - getInitialProps: F, -): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise>; - -/** - * Wraps a `getServerSideProps` function with Sentry error and performance instrumentation. - * - * @param origGetServerSideProps The `getServerSideProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export declare function wrapGetServerSidePropsWithSentry any>( - origGetServerSideProps: F, - parameterizedRoute: string, -): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise>; - -/** - * Wraps a `getStaticProps` function with Sentry error and performance instrumentation. - * - * @param origGetStaticProps The `getStaticProps` function - * @param parameterizedRoute The page's parameterized route - * @returns A wrapped version of the function - */ -export declare function wrapGetStaticPropsWithSentry any>( - origGetStaticPropsa: F, - parameterizedRoute: string, -): (...args: Parameters) => ReturnType extends Promise ? ReturnType : Promise>; - -/** - * Wraps an `app` directory server component with Sentry error and performance instrumentation. - */ -export declare function wrapServerComponentWithSentry any>( - WrappingTarget: F, - context: ServerComponentContext, -): F; - -/** - * Wraps an `app` directory server component with Sentry error and performance instrumentation. - */ -export declare function wrapApiHandlerWithSentryVercelCrons any>( - WrappingTarget: F, - vercelCronsConfig: VercelCronsConfig, -): F; - -/** - * Wraps a page component with Sentry error instrumentation. - */ -export declare function wrapPageComponentWithSentry(WrappingTarget: C): C; - -// eslint-disable-next-line deprecation/deprecation -export { experimental_captureRequestError, captureRequestError } from './common/captureRequestError'; diff --git a/packages/nextjs/src/server/distDirRewriteFramesIntegration.ts b/packages/nextjs/src/server/distDirRewriteFramesIntegration.ts deleted file mode 100644 index 8ac80d91d61c..000000000000 --- a/packages/nextjs/src/server/distDirRewriteFramesIntegration.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as path from 'path'; -import { defineIntegration, rewriteFramesIntegration } from '@sentry/core'; -import { escapeStringForRegex } from '@sentry/utils'; - -export const distDirRewriteFramesIntegration = defineIntegration(({ distDirName }: { distDirName: string }) => { - // nextjs always puts the build directory at the project root level, which is also where you run `next start` from, so - // we can read in the project directory from the currently running process - const distDirAbsPath = path.resolve(distDirName).replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one - - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped - const SOURCEMAP_FILENAME_REGEX = new RegExp(escapeStringForRegex(distDirAbsPath)); - - const rewriteFramesInstance = rewriteFramesIntegration({ - iteratee: frame => { - frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); - return frame; - }, - }); - - return { - ...rewriteFramesInstance, - name: 'DistDirRewriteFrames', - }; -}); diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts deleted file mode 100644 index 1132a6e1eed2..000000000000 --- a/packages/nextjs/src/server/index.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - applySdkMetadata, - getClient, - getGlobalScope, - getRootSpan, - spanToJSON, -} from '@sentry/core'; -import { getDefaultIntegrations, init as nodeInit } from '@sentry/node'; -import type { NodeClient, NodeOptions } from '@sentry/node'; -import { GLOBAL_OBJ, logger } from '@sentry/utils'; - -import { SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_ROUTE, SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; -import type { EventProcessor } from '@sentry/types'; -import { DEBUG_BUILD } from '../common/debug-build'; -import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; -import { getVercelEnv } from '../common/getVercelEnv'; -import { isBuild } from '../common/utils/isBuild'; -import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; - -export * from '@sentry/node'; - -export { captureUnderscoreErrorException } from '../common/_error'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __rewriteFramesDistDir__?: string; - __sentryRewritesTunnelPath__?: string; -}; - -// https://github.com/lforst/nextjs-fork/blob/9051bc44d969a6e0ab65a955a2fc0af522a83911/packages/next/src/server/lib/trace/constants.ts#L11 -const NEXTJS_SPAN_NAME_PREFIXES = [ - 'BaseServer.', - 'LoadComponents.', - 'NextServer.', - 'createServer.', - 'startServer.', - 'NextNodeServer.', - 'Render.', - 'AppRender.', - 'Router.', - 'Node.', - 'AppRouteRouteHandlers.', - 'ResolveMetadata.', -]; - -/** - * A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors - * so they should simply be a passthrough. - */ -export const ErrorBoundary = (props: React.PropsWithChildren): React.ReactNode => { - if (!props.children) { - return null; - } - - if (typeof props.children === 'function') { - return (props.children as () => React.ReactNode)(); - } - - // since Next.js >= 10 requires React ^16.6.0 we are allowed to return children like this here - return props.children as React.ReactNode; -}; - -/** - * A passthrough redux enhancer for the server that doesn't depend on anything from the `@sentry/react` package. - */ -export function createReduxEnhancer() { - return (createStore: unknown) => createStore; -} - -/** - * A passthrough error boundary wrapper for the server that doesn't depend on any react. Error boundaries don't catch - * SSR errors so they should simply be a passthrough. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withErrorBoundary

>( - WrappedComponent: React.ComponentType

, -): React.FC

{ - return WrappedComponent as React.FC

; -} - -/** - * Just a passthrough since we're on the server and showing the report dialog on the server doesn't make any sense. - */ -export function showReportDialog(): void { - return; -} - -/** Inits the Sentry NextJS SDK on node. */ -export function init(options: NodeOptions): NodeClient | undefined { - if (isBuild()) { - return; - } - - const customDefaultIntegrations = getDefaultIntegrations(options); - - // Turn off Next.js' own fetch instrumentation - // https://github.com/lforst/nextjs-fork/blob/1994fd186defda77ad971c36dc3163db263c993f/packages/next/src/server/lib/patch-fetch.ts#L245 - process.env.NEXT_OTEL_FETCH_DISABLED = '1'; - - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; - if (distDirName) { - customDefaultIntegrations.push(distDirRewriteFramesIntegration({ distDirName })); - } - - const opts: NodeOptions = { - environment: process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV, - defaultIntegrations: customDefaultIntegrations, - ...options, - // Right now we only capture frontend sessions for Next.js - autoSessionTracking: false, - }; - - if (DEBUG_BUILD && opts.debug) { - logger.enable(); - } - - DEBUG_BUILD && logger.log('Initializing SDK...'); - - if (sdkAlreadyInitialized()) { - DEBUG_BUILD && logger.log('SDK already initialized'); - return; - } - - applySdkMetadata(opts, 'nextjs', ['nextjs', 'node']); - - const client = nodeInit(opts); - client?.on('beforeSampling', ({ spanAttributes, spanName, parentSampled, parentContext }, samplingDecision) => { - // We allowlist the "BaseServer.handleRequest" span, since that one is responsible for App Router requests, which are actually useful for us. - // HOWEVER, that span is not only responsible for App Router requests, which is why we additionally filter for certain transactions in an - // event processor further below. - if (spanAttributes['next.span_type'] === 'BaseServer.handleRequest') { - return; - } - - // If we encounter a span emitted by Next.js, we do not want to sample it - // The reason for this is that the data quality of the spans varies, it is different per version of Next, - // and we need to keep our manual instrumentation around for the edge runtime anyhow. - // BUT we only do this if we don't have a parent span with a sampling decision yet (or if the parent is remote) - if ( - (spanAttributes['next.span_type'] || NEXTJS_SPAN_NAME_PREFIXES.some(prefix => spanName.startsWith(prefix))) && - (parentSampled === undefined || parentContext?.isRemote) - ) { - samplingDecision.decision = false; - } - - // There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in - // middleware) and this causes spans for Sentry ingest requests to be created. These are not exempt from our tracing - // because we didn't get the chance to do `suppressTracing`, since this happens outside of userland. - // We need to drop these spans. - if ( - typeof spanAttributes[SEMATTRS_HTTP_TARGET] === 'string' && - spanAttributes[SEMATTRS_HTTP_TARGET].includes('sentry_key') && - spanAttributes[SEMATTRS_HTTP_TARGET].includes('sentry_client') - ) { - samplingDecision.decision = false; - } - }); - - client?.on('spanStart', span => { - const spanAttributes = spanToJSON(span).data; - - // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted - // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. - if (spanAttributes?.['next.route']) { - const rootSpan = getRootSpan(span); - const rootSpanAttributes = spanToJSON(rootSpan).data; - - // Only hoist the http.route attribute if the transaction doesn't already have it - if (rootSpanAttributes?.[SEMATTRS_HTTP_METHOD] && !rootSpanAttributes?.[SEMATTRS_HTTP_ROUTE]) { - rootSpan.setAttribute(SEMATTRS_HTTP_ROUTE, spanAttributes['next.route']); - } - } - - // We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans - // with patterns (e.g. http.server spans) that will produce confusing data. - if (spanAttributes?.['next.span_type'] !== undefined) { - span.setAttribute('sentry.skip_span_data_inference', true); - } - - // We want to rename these spans because they look like "GET /path/to/route" and we already emit spans that look - // like this with our own http instrumentation. - if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest') { - span.updateName('next server handler'); // This is all lowercase because the spans that Next.js emits by itself generally look like this. - } - }); - - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - if (event.type === 'transaction') { - // Filter out transactions for static assets - // This regex matches the default path to the static assets (`_next/static`) and could potentially filter out too many transactions. - // We match `/_next/static/` anywhere in the transaction name because its location may change with the basePath setting. - if (event.transaction?.match(/^GET (\/.*)?\/_next\/static\//)) { - return null; - } - - // We only want to use our HTTP integration/instrumentation for app router requests, which are marked with the `sentry.rsc` attribute. - if ( - (event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.http' || - event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest') && - event.contexts?.trace?.data?.['sentry.rsc'] !== true - ) { - return null; - } - - // Filter out transactions for requests to the tunnel route - if ( - globalWithInjectedValues.__sentryRewritesTunnelPath__ && - event.transaction === `POST ${globalWithInjectedValues.__sentryRewritesTunnelPath__}` - ) { - return null; - } - - // Filter out requests to resolve source maps for stack frames in dev mode - if (event.transaction?.match(/\/__nextjs_original-stack-frame/)) { - return null; - } - - // Filter out /404 transactions for pages-router which seem to be created excessively - if (event.transaction === '/404') { - return null; - } - - return event; - } else { - return event; - } - }) satisfies EventProcessor, - { id: 'NextLowQualityTransactionsFilter' }, - ), - ); - - getGlobalScope().addEventProcessor( - Object.assign( - ((event, hint) => { - if (event.type !== undefined) { - return event; - } - - const originalException = hint.originalException; - - const isPostponeError = - typeof originalException === 'object' && - originalException !== null && - '$$typeof' in originalException && - originalException.$$typeof === Symbol.for('react.postpone'); - - if (isPostponeError) { - // Postpone errors are used for partial-pre-rendering (PPR) - return null; - } - - // We don't want to capture suspense errors as they are simply used by React/Next.js for control flow - const exceptionMessage = event.exception?.values?.[0]?.value; - if ( - exceptionMessage?.includes('Suspense Exception: This is not a real error!') || - exceptionMessage?.includes('Suspense Exception: This is not a real error, and should not leak') - ) { - return null; - } - - return event; - }) satisfies EventProcessor, - { id: 'DropReactControlFlowErrors' }, - ), - ); - - if (process.env.NODE_ENV === 'development') { - getGlobalScope().addEventProcessor(devErrorSymbolicationEventProcessor); - } - - DEBUG_BUILD && logger.log('SDK successfully initialized'); - - return client; -} - -function sdkAlreadyInitialized(): boolean { - return !!getClient(); -} - -export * from '../common'; - -export { wrapApiHandlerWithSentry } from '../common/wrapApiHandlerWithSentry'; diff --git a/packages/nextjs/src/server/rewriteFramesIntegration.ts b/packages/nextjs/src/server/rewriteFramesIntegration.ts deleted file mode 100644 index 6438ccb0d922..000000000000 --- a/packages/nextjs/src/server/rewriteFramesIntegration.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as path from 'path'; -import { defineIntegration, rewriteFramesIntegration as originalRewriteFramesIntegration } from '@sentry/core'; -import type { IntegrationFn, StackFrame } from '@sentry/types'; -import { escapeStringForRegex } from '@sentry/utils'; - -const globalWithInjectedValues = global as typeof global & { - __rewriteFramesDistDir__?: string; -}; - -type StackFrameIteratee = (frame: StackFrame) => StackFrame; -interface RewriteFramesOptions { - root?: string; - prefix?: string; - iteratee?: StackFrameIteratee; -} - -export const customRewriteFramesIntegration = ((options?: RewriteFramesOptions) => { - // This value is injected at build time, based on the output directory specified in the build config. Though a default - // is set there, we set it here as well, just in case something has gone wrong with the injection. - const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__; - - if (distDirName) { - // nextjs always puts the build directory at the project root level, which is also where you run `next start` from, so - // we can read in the project directory from the currently running process - const distDirAbsPath = path.resolve(distDirName).replace(/(\/|\\)$/, ''); // We strip trailing slashes because "app:///_next" also doesn't have one - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- user input is escaped - const SOURCEMAP_FILENAME_REGEX = new RegExp(escapeStringForRegex(distDirAbsPath)); - - return originalRewriteFramesIntegration({ - iteratee: frame => { - frame.filename = frame.filename?.replace(SOURCEMAP_FILENAME_REGEX, 'app:///_next'); - return frame; - }, - ...options, - }); - } - - // Do nothing if we can't find a distDirName - return { - name: 'RewriteFrames', - }; -}) satisfies IntegrationFn; - -export const rewriteFramesIntegration = defineIntegration(customRewriteFramesIntegration); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts deleted file mode 100644 index ac159564410b..000000000000 --- a/packages/nextjs/test/clientSdk.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { getGlobalScope, getIsolationScope } from '@sentry/core'; -import * as SentryReact from '@sentry/react'; -import { WINDOW, getClient, getCurrentScope } from '@sentry/react'; -import type { Integration } from '@sentry/types'; -import { logger } from '@sentry/utils'; -import { JSDOM } from 'jsdom'; - -import { breadcrumbsIntegration, browserTracingIntegration, init } from '../src/client'; - -const reactInit = jest.spyOn(SentryReact, 'init'); -const loggerLogSpy = jest.spyOn(logger, 'log'); - -// We're setting up JSDom here because the Next.js routing instrumentations requires a few things to be present on pageload: -// 1. Access to window.document API for `window.document.getElementById` -// 2. Access to window.location API for `window.location.pathname` -const dom = new JSDOM(undefined, { url: 'https://example.com/' }); -Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); -Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); - -const originalGlobalDocument = WINDOW.document; -const originalGlobalLocation = WINDOW.location; -afterAll(() => { - // Clean up JSDom - Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); - Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); -}); - -function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { - return integrations.find(integration => integration.name === name); -} - -const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; - -describe('Client init()', () => { - afterEach(() => { - jest.clearAllMocks(); - - getGlobalScope().clear(); - getIsolationScope().clear(); - getCurrentScope().clear(); - getCurrentScope().setClient(undefined); - }); - - it('inits the React SDK', () => { - expect(reactInit).toHaveBeenCalledTimes(0); - init({}); - expect(reactInit).toHaveBeenCalledTimes(1); - expect(reactInit).toHaveBeenCalledWith( - expect.objectContaining({ - _metadata: { - sdk: { - name: 'sentry.javascript.nextjs', - version: expect.any(String), - packages: [ - { - name: 'npm:@sentry/nextjs', - version: expect.any(String), - }, - { - name: 'npm:@sentry/react', - version: expect.any(String), - }, - ], - }, - }, - environment: 'test', - defaultIntegrations: expect.arrayContaining([ - expect.objectContaining({ - name: 'NextjsClientStackFrameNormalization', - }), - ]), - }), - ); - }); - - it('adds 404 transaction filter', () => { - init({ - dsn: 'https://dogsarebadatkeepingsecrets@squirrelchasers.ingest.sentry.io/12312012', - tracesSampleRate: 1.0, - }); - const transportSend = jest.spyOn(getClient()!.getTransport()!, 'send'); - - // Ensure we have no current span, so our next span is a transaction - SentryReact.withActiveSpan(null, () => { - SentryReact.startInactiveSpan({ name: '/404' })?.end(); - }); - - expect(transportSend).not.toHaveBeenCalled(); - expect(loggerLogSpy).toHaveBeenCalledWith('An event processor returned `null`, will not send event.'); - }); - - describe('integrations', () => { - // Options passed by `@sentry/nextjs`'s `init` to `@sentry/react`'s `init` after modifying them - type ModifiedInitOptionsIntegrationArray = { defaultIntegrations: Integration[]; integrations: Integration[] }; - - it('supports passing unrelated integrations through options', () => { - init({ integrations: [breadcrumbsIntegration({ console: false })] }); - - const reactInitOptions = reactInit.mock.calls[0]![0] as ModifiedInitOptionsIntegrationArray; - const installedBreadcrumbsIntegration = findIntegrationByName(reactInitOptions.integrations, 'Breadcrumbs'); - - expect(installedBreadcrumbsIntegration).toBeDefined(); - }); - - it('forces correct router instrumentation if user provides `browserTracingIntegration` in an array', () => { - const providedBrowserTracingInstance = browserTracingIntegration(); - - const client = init({ - dsn: TEST_DSN, - tracesSampleRate: 1.0, - integrations: [providedBrowserTracingInstance], - }); - - const integration = client?.getIntegrationByName('BrowserTracing'); - expect(integration).toBe(providedBrowserTracingInstance); - }); - - it('forces correct router instrumentation if user provides `BrowserTracing` in a function', () => { - const providedBrowserTracingInstance = browserTracingIntegration(); - - const client = init({ - dsn: TEST_DSN, - tracesSampleRate: 1.0, - integrations: defaults => [...defaults, providedBrowserTracingInstance], - }); - - const integration = client?.getIntegrationByName('BrowserTracing'); - - expect(integration).toBe(providedBrowserTracingInstance); - }); - - describe('browserTracingIntegration()', () => { - it('adds the browserTracingIntegration when `__SENTRY_TRACING__` is not set', () => { - const client = init({ - dsn: TEST_DSN, - }); - - const browserTracingIntegration = client?.getIntegrationByName('BrowserTracing'); - expect(browserTracingIntegration).toBeDefined(); - }); - - it("doesn't add a browserTracingIntegration if `__SENTRY_TRACING__` is set to false", () => { - // @ts-expect-error Test setup for build-time flag - globalThis.__SENTRY_TRACING__ = false; - - const client = init({ - dsn: TEST_DSN, - }); - - const browserTracingIntegration = client?.getIntegrationByName('BrowserTracing'); - expect(browserTracingIntegration).toBeUndefined(); - - // @ts-expect-error Test setup for build-time flag - delete globalThis.__SENTRY_TRACING__; - }); - }); - }); - - it('returns client from init', () => { - expect(init({})).not.toBeUndefined(); - }); -}); diff --git a/packages/nextjs/test/config/fixtures.ts b/packages/nextjs/test/config/fixtures.ts deleted file mode 100644 index a3c4feb0123b..000000000000 --- a/packages/nextjs/test/config/fixtures.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { - BuildContext, - EntryPropertyFunction, - ExportedNextConfig, - NextConfigObject, - WebpackConfigObject, -} from '../../src/config/types'; - -export const SERVER_SDK_CONFIG_FILE = 'sentry.server.config.js'; -export const CLIENT_SDK_CONFIG_FILE = 'sentry.client.config.js'; -export const EDGE_SDK_CONFIG_FILE = 'sentry.edge.config.js'; - -/** Mock next config object */ -export const userNextConfig: NextConfigObject = { - publicRuntimeConfig: { location: 'dogpark', activities: ['fetch', 'chasing', 'digging'] }, - pageExtensions: ['jsx', 'js', 'tsx', 'ts', 'custom.jsx', 'custom.js', 'custom.tsx', 'custom.ts'], - webpack: (incomingWebpackConfig: WebpackConfigObject, _options: BuildContext) => ({ - ...incomingWebpackConfig, - mode: 'universal-sniffing', - entry: async () => - Promise.resolve({ - ...(await (incomingWebpackConfig.entry as EntryPropertyFunction)()), - simulatorBundle: './src/simulator/index.ts', - }), - }), -}; - -/** Mocks of the arguments passed to `withSentryConfig` */ -export const exportedNextConfig = userNextConfig; -export const userSentryWebpackPluginConfig = { org: 'squirrelChasers', project: 'simulator' }; -process.env.SENTRY_AUTH_TOKEN = 'dogsarebadatkeepingsecrets'; -process.env.SENTRY_RELEASE = 'doGsaREgReaT'; - -/** Mocks of the arguments passed to the result of `withSentryConfig` (when it's a function). */ -export const defaultRuntimePhase = 'ball-fetching'; -// `defaultConfig` is the defaults for all nextjs options (we don't use these at all in the tests, so for our purposes -// here the values don't matter) -export const defaultsObject = { defaultConfig: {} as NextConfigObject }; - -/** mocks of the arguments passed to `nextConfig.webpack` */ -export const serverWebpackConfig: WebpackConfigObject = { - entry: () => - Promise.resolve({ - 'pages/_error': 'private-next-pages/_error.js', - 'pages/_app': 'private-next-pages/_app.js', - 'pages/sniffTour': ['./node_modules/smellOVision/index.js', 'private-next-pages/sniffTour.js'], - middleware: 'private-next-pages/middleware.js', - 'pages/api/simulator/dogStats/[name]': { import: 'private-next-pages/api/simulator/dogStats/[name].js' }, - 'pages/simulator/leaderboard': { - import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/simulator/leaderboard.js'], - }, - 'pages/api/tricks/[trickName]': { - import: 'private-next-pages/api/tricks/[trickName].js', - dependOn: 'treats', - }, - treats: './node_modules/dogTreats/treatProvider.js', - }), - output: { filename: '[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' }, - target: 'node', - context: '/Users/Maisey/projects/squirrelChasingSimulator', - resolve: { alias: { 'private-next-pages': '/Users/Maisey/projects/squirrelChasingSimulator/pages' } }, -}; -export const clientWebpackConfig: WebpackConfigObject = { - entry: () => - Promise.resolve({ - main: './src/index.ts', - 'pages/_app': 'next-client-pages-loader?page=%2F_app', - 'pages/_error': 'next-client-pages-loader?page=%2F_error', - 'pages/sniffTour': ['./node_modules/smellOVision/index.js', 'private-next-pages/sniffTour.js'], - 'pages/simulator/leaderboard': { - import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/simulator/leaderboard.js'], - }, - }), - output: { filename: 'static/chunks/[name].js', path: '/Users/Maisey/projects/squirrelChasingSimulator/.next' }, - target: 'web', - context: '/Users/Maisey/projects/squirrelChasingSimulator', -}; - -/** - * Return a mock build context, including the user's next config (which nextjs copies in in real life). - * - * @param buildTarget 'server' or 'client' - * @param materializedNextConfig The user's next config - * @param webpackVersion - * @returns A mock build context for the given target - */ -export function getBuildContext( - buildTarget: 'server' | 'client' | 'edge', - materializedNextConfig: ExportedNextConfig, - webpackVersion: string = '5.4.15', -): BuildContext { - return { - dev: false, - buildId: 'sItStAyLiEdOwN', - dir: '/Users/Maisey/projects/squirrelChasingSimulator', - config: { - // nextjs's default values - target: 'server', - distDir: '.next', - ...materializedNextConfig, - } as NextConfigObject, - webpack: { version: webpackVersion, DefinePlugin: class {} as any }, - defaultLoaders: true, - totalPages: 2, - isServer: buildTarget === 'server' || buildTarget === 'edge', - nextRuntime: ({ server: 'nodejs', client: undefined, edge: 'edge' } as const)[buildTarget], - }; -} - -export const serverBuildContext = getBuildContext('server', exportedNextConfig); -export const clientBuildContext = getBuildContext('client', exportedNextConfig); -export const edgeBuildContext = getBuildContext('edge', exportedNextConfig); diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts deleted file mode 100644 index c559ee643baf..000000000000 --- a/packages/nextjs/test/config/loaders.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -// mock helper functions not tested directly in this file -import './mocks'; - -import * as fs from 'fs'; - -import type { ModuleRuleUseProperty, WebpackModuleRule } from '../../src/config/types'; -import { - clientBuildContext, - clientWebpackConfig, - exportedNextConfig, - serverBuildContext, - serverWebpackConfig, -} from './fixtures'; -import { materializeFinalWebpackConfig } from './testUtils'; - -const existsSyncSpy = jest.spyOn(fs, 'existsSync'); -const lstatSyncSpy = jest.spyOn(fs, 'lstatSync'); - -type MatcherResult = { pass: boolean; message: () => string }; - -expect.extend({ - stringEndingWith(received: string, expectedEnding: string): MatcherResult { - const failsTest = !received.endsWith(expectedEnding); - const generateErrorMessage = () => - failsTest - ? // Regular error message for match failing - `expected string ending with '${expectedEnding}', but got '${received}'` - : // Error message for the match passing if someone has called it with `expect.not` - `expected string not ending with '${expectedEnding}', but got '${received}'`; - - return { - pass: !failsTest, - message: generateErrorMessage, - }; - }, -}); - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace jest { - interface Expect { - stringEndingWith: (expectedEnding: string) => MatcherResult; - } - } -} - -function applyRuleToResource(rule: WebpackModuleRule, resourcePath: string): ModuleRuleUseProperty[] { - const applications = []; - - let shouldApply: boolean = false; - if (typeof rule.test === 'function') { - shouldApply = rule.test(resourcePath); - } else if (rule.test instanceof RegExp) { - shouldApply = !!resourcePath.match(rule.test); - } else if (rule.test) { - shouldApply = resourcePath === rule.test; - } - - if (shouldApply) { - if (Array.isArray(rule.use)) { - applications.push(...rule.use); - } else if (rule.use) { - applications.push(rule.use); - } - } - - return applications; -} - -describe('webpack loaders', () => { - describe('server loaders', () => { - it('adds server `valueInjection` loader to server config', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig.module.rules).toContainEqual({ - test: expect.any(RegExp), - use: [ - { - loader: expect.stringEndingWith('valueInjectionLoader.js'), - // We use `expect.objectContaining({})` rather than `expect.any(Object)` to match any plain object because - // the latter will also match arrays, regexes, dates, sets, etc. - anything whose `typeof` value is - // `'object'`. - options: expect.objectContaining({ values: expect.objectContaining({}) }), - }, - ], - }); - }); - - // For these tests we assume that we have an app and pages folder in {rootdir}/src - it.each([ - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/testPage.tsx', - expectedWrappingTargetKind: 'page', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/testPage.custom.tsx', - expectedWrappingTargetKind: 'page', - }, - { - resourcePath: './src/pages/testPage.tsx', - expectedWrappingTargetKind: 'page', - }, - { - resourcePath: './pages/testPage.tsx', - expectedWrappingTargetKind: undefined, - }, - { - resourcePath: '../src/pages/testPage.tsx', - expectedWrappingTargetKind: undefined, - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/nested/testPage.ts', - expectedWrappingTargetKind: 'page', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/nested/testPage.js', - expectedWrappingTargetKind: 'page', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/[nested]/[testPage].js', - expectedWrappingTargetKind: 'page', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/[...testPage].js', - expectedWrappingTargetKind: 'page', - }, - // Regression test for https://github.com/getsentry/sentry-javascript/issues/7122 - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/apidoc/[version].tsx', - expectedWrappingTargetKind: 'page', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.js', - expectedWrappingTargetKind: 'middleware', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.custom.js', - expectedWrappingTargetKind: 'middleware', - }, - { - resourcePath: './src/middleware.js', - expectedWrappingTargetKind: 'middleware', - }, - { - resourcePath: './middleware.js', - expectedWrappingTargetKind: undefined, - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/middleware.ts', - expectedWrappingTargetKind: 'middleware', - }, - // Since we assume we have a pages file in src middleware will only be included in the build if it is also in src - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/middleware.tsx', - expectedWrappingTargetKind: undefined, - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/testApiRoute.ts', - expectedWrappingTargetKind: 'api-route', - }, - { - resourcePath: './src/pages/api/testApiRoute.ts', - expectedWrappingTargetKind: 'api-route', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/nested/testApiRoute.js', - expectedWrappingTargetKind: 'api-route', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/nested/testApiRoute.custom.js', - expectedWrappingTargetKind: 'api-route', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/nested/route.ts', - expectedWrappingTargetKind: 'route-handler', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/nested/route.custom.ts', - expectedWrappingTargetKind: 'route-handler', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/page.js', - expectedWrappingTargetKind: 'server-component', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/page.custom.js', - expectedWrappingTargetKind: 'server-component', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/nested/page.js', - expectedWrappingTargetKind: 'server-component', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/nested/page.ts', - expectedWrappingTargetKind: 'server-component', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/(group)/nested/page.tsx', - expectedWrappingTargetKind: 'server-component', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/(group)/nested/loading.ts', - expectedWrappingTargetKind: 'server-component', - }, - { - resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/app/layout.js', - expectedWrappingTargetKind: 'server-component', - }, - ])( - 'should apply the right wrappingTargetKind with wrapping loader ($resourcePath)', - async ({ resourcePath, expectedWrappingTargetKind }) => { - // We assume that we have an app and pages folder in {rootdir}/src - existsSyncSpy.mockImplementation(path => { - if ( - path.toString().startsWith('/Users/Maisey/projects/squirrelChasingSimulator/app') || - path.toString().startsWith('/Users/Maisey/projects/squirrelChasingSimulator/pages') - ) { - return false; - } - return true; - }); - - // @ts-expect-error Too lazy to mock the entire thing - lstatSyncSpy.mockImplementation(() => ({ - isDirectory: () => true, - })); - - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - const loaderApplications: ModuleRuleUseProperty[] = []; - finalWebpackConfig.module.rules.forEach(rule => { - loaderApplications.push(...applyRuleToResource(rule, resourcePath)); - }); - - if (expectedWrappingTargetKind) { - expect(loaderApplications).toContainEqual( - expect.objectContaining({ - loader: expect.stringMatching(/wrappingLoader\.js$/), - options: expect.objectContaining({ - wrappingTargetKind: expectedWrappingTargetKind, - }), - }), - ); - } else { - expect(loaderApplications).not.toContainEqual( - expect.objectContaining({ - loader: expect.stringMatching(/wrappingLoader\.js$/), - }), - ); - } - }, - ); - }); - - describe('client loaders', () => { - it('adds `valueInjection` loader to client config', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: clientWebpackConfig, - incomingWebpackBuildContext: clientBuildContext, - }); - - expect(finalWebpackConfig.module.rules).toContainEqual({ - test: /sentry\.client\.config\.(jsx?|tsx?)/, - use: [ - { - loader: expect.stringEndingWith('valueInjectionLoader.js'), - // We use `expect.objectContaining({})` rather than `expect.any(Object)` to match any plain object because - // the latter will also match arrays, regexes, dates, sets, etc. - anything whose `typeof` value is - // `'object'`. - options: expect.objectContaining({ values: expect.objectContaining({}) }), - }, - ], - }); - }); - }); -}); - -describe('`distDir` value in default server-side `RewriteFrames` integration', () => { - describe('`RewriteFrames` ends up with correct `distDir` value', () => { - // TODO: this, along with any number of other parts of the build process, should be tested with an integration - // test which actually runs webpack and inspects the resulting bundles (and that integration test should test - // custom `distDir` values with and without a `.`, to make sure the regex escaping is working) - }); -}); diff --git a/packages/nextjs/test/config/mocks.ts b/packages/nextjs/test/config/mocks.ts deleted file mode 100644 index 5c27c743c9f9..000000000000 --- a/packages/nextjs/test/config/mocks.ts +++ /dev/null @@ -1,74 +0,0 @@ -// TODO: This mocking is why we have to use `--runInBand` when we run tests, since there's only a single temp directory -// created - -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { CLIENT_SDK_CONFIG_FILE, EDGE_SDK_CONFIG_FILE, SERVER_SDK_CONFIG_FILE } from './fixtures'; - -// We use `fs.existsSync()` in `getUserConfigFile()`. When we're not testing `getUserConfigFile()` specifically, all we -// need is for it to give us any valid answer, so make it always find what it's looking for. Since this is a core node -// built-in, though, which jest itself uses, otherwise let it do the normal thing. Storing the real version of the -// function also lets us restore the original when we do want to test `getUserConfigFile()`. -export const realExistsSync = jest.requireActual('fs').existsSync; -export const mockExistsSync = (path: fs.PathLike): ReturnType => { - if ( - (path as string).endsWith(SERVER_SDK_CONFIG_FILE) || - (path as string).endsWith(CLIENT_SDK_CONFIG_FILE) || - (path as string).endsWith(EDGE_SDK_CONFIG_FILE) - ) { - return true; - } - - return realExistsSync(path); -}; -export const exitsSync = jest.spyOn(fs, 'existsSync').mockImplementation(mockExistsSync); - -/** Mocking of temporary directory creation (so that we have a place to stick files (like `sentry.client.config.js`) in - * order to test that we can find them) */ - -// Make it so that all temporary folders, either created directly by tests or by the code they're testing, will go into -// one spot that we know about, which we can then clean up when we're done -const realTmpdir = jest.requireActual('os').tmpdir; - -// Including the random number ensures that even if multiple test files using these mocks are running at once, they have -// separate temporary folders -const TEMP_DIR_PATH = path.join(realTmpdir(), `sentry-nextjs-test-${Math.random()}`); - -jest.spyOn(os, 'tmpdir').mockReturnValue(TEMP_DIR_PATH); -// In theory, we should always land in the `else` here, but this saves the cases where the prior run got interrupted and -// the `afterAll` below didn't happen. -if (fs.existsSync(TEMP_DIR_PATH)) { - fs.rmSync(TEMP_DIR_PATH, { recursive: true, force: true }); -} - -fs.mkdirSync(TEMP_DIR_PATH); - -afterAll(() => { - fs.rmSync(TEMP_DIR_PATH, { recursive: true, force: true }); -}); - -// In order to know what to expect in the webpack config `entry` property, we need to know the path of the temporary -// directory created when doing the file injection, so wrap the real `mkdtempSync` and store the resulting path where we -// can access it -export const mkdtempSyncSpy = jest.spyOn(fs, 'mkdtempSync'); - -afterEach(() => { - mkdtempSyncSpy.mockClear(); -}); - -// TODO (v8): This shouldn't be necessary once `hideSourceMaps` gets a default value, even for the updated error message -// eslint-disable-next-line @typescript-eslint/unbound-method -const realConsoleWarn = global.console.warn; -global.console.warn = (...args: unknown[]) => { - // Suppress the warning message about the `hideSourceMaps` option. This is better than forcing a value for - // `hideSourceMaps` because that would mean we couldn't test it easily and would muddy the waters of other tests. Note - // that doing this here, as a side effect, only works because the tests which trigger this warning are the same tests - // which need other mocks from this file. - if (typeof args[0] === 'string' && args[0]?.includes('your original code may be visible in browser devtools')) { - return; - } - - return realConsoleWarn(...args); -}; diff --git a/packages/nextjs/test/config/testUtils.ts b/packages/nextjs/test/config/testUtils.ts deleted file mode 100644 index 1e93e3740152..000000000000 --- a/packages/nextjs/test/config/testUtils.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { - BuildContext, - EntryPropertyFunction, - ExportedNextConfig, - NextConfigObject, - SentryBuildOptions, - WebpackConfigObject, - WebpackConfigObjectWithModuleRules, -} from '../../src/config/types'; -import { constructWebpackConfigFunction } from '../../src/config/webpack'; -import { withSentryConfig } from '../../src/config/withSentryConfig'; -import { defaultRuntimePhase, defaultsObject } from './fixtures'; - -/** - * Derive the final values of all next config options, by first applying `withSentryConfig` and then, if it returns a - * function, running that function. - * - * @param exportedNextConfig Next config options provided by the user - * @param userSentryWebpackPluginConfig SentryWebpackPlugin options provided by the user - * - * @returns The config values next will receive directly from `withSentryConfig` or when it calls the function returned - * by `withSentryConfig` - */ -export function materializeFinalNextConfig( - exportedNextConfig: ExportedNextConfig, - runtimePhase?: string, - sentryBuildOptions?: SentryBuildOptions, -): NextConfigObject { - const sentrifiedConfig = withSentryConfig(exportedNextConfig, sentryBuildOptions); - let finalConfigValues = sentrifiedConfig; - - if (typeof sentrifiedConfig === 'function') { - // for some reason TS won't recognize that `finalConfigValues` is now a NextConfigObject, which is why the cast - // below is necessary - finalConfigValues = sentrifiedConfig(runtimePhase ?? defaultRuntimePhase, defaultsObject) as NextConfigObject; - } - - return finalConfigValues as NextConfigObject; -} - -/** - * Derive the final values of all webpack config options, by first applying `constructWebpackConfigFunction` and then - * running the resulting function. Since the `entry` property of the resulting object is itself a function, also call - * that. - * - * @param options An object including the following: - * - `exportedNextConfig` Next config options provided by the user - * - `userSentryWebpackPluginConfig` SentryWebpackPlugin options provided by the user - * - `incomingWebpackConfig` The existing webpack config, passed to the function as `config` - * - `incomingWebpackBuildContext` The existing webpack build context, passed to the function as `options` - * - * @returns The webpack config values next will use when it calls the function that `createFinalWebpackConfig` returns - */ -export async function materializeFinalWebpackConfig(options: { - exportedNextConfig: ExportedNextConfig; - incomingWebpackConfig: WebpackConfigObject; - incomingWebpackBuildContext: BuildContext; - sentryBuildTimeOptions?: SentryBuildOptions; -}): Promise { - const { exportedNextConfig, incomingWebpackConfig, incomingWebpackBuildContext } = options; - - // if the user's next config is a function, run it so we have access to the values - const materializedUserNextConfig = - typeof exportedNextConfig === 'function' - ? await exportedNextConfig('phase-production-build', defaultsObject) - : exportedNextConfig; - - // get the webpack config function we'd normally pass back to next - const webpackConfigFunction = constructWebpackConfigFunction( - materializedUserNextConfig, - options.sentryBuildTimeOptions, - ); - - // call it to get concrete values for comparison - const finalWebpackConfigValue = webpackConfigFunction(incomingWebpackConfig, incomingWebpackBuildContext); - const webpackEntryProperty = finalWebpackConfigValue.entry as EntryPropertyFunction; - finalWebpackConfigValue.entry = await webpackEntryProperty(); - - return finalWebpackConfigValue as WebpackConfigObjectWithModuleRules; -} diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts deleted file mode 100644 index a9d8b812f8a9..000000000000 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// mock helper functions not tested directly in this file -import '../mocks'; - -import { - CLIENT_SDK_CONFIG_FILE, - clientBuildContext, - clientWebpackConfig, - exportedNextConfig, - serverBuildContext, - serverWebpackConfig, - userNextConfig, -} from '../fixtures'; -import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; - -describe('constructWebpackConfigFunction()', () => { - it('includes expected properties', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - expect(finalWebpackConfig).toEqual( - expect.objectContaining({ - devtool: 'source-map', - entry: expect.any(Object), // `entry` is tested specifically elsewhere - plugins: expect.arrayContaining([expect.objectContaining({ _name: 'sentry-webpack-plugin' })]), - }), - ); - }); - - it('preserves unrelated webpack config options', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - }); - - // Run the user's webpack config function, so we can check the results against ours. Delete `entry` because we'll - // test it separately, and besides, it's one that we *should* be overwriting. - const materializedUserWebpackConfig = userNextConfig.webpack!(serverWebpackConfig, serverBuildContext); - // @ts-expect-error `entry` may be required in real life, but we don't need it for our tests - delete materializedUserWebpackConfig.entry; - - expect(finalWebpackConfig).toEqual(expect.objectContaining(materializedUserWebpackConfig)); - }); - - it("doesn't set devtool if webpack plugin is disabled", () => { - const finalNextConfig = materializeFinalNextConfig( - { - ...exportedNextConfig, - webpack: () => - ({ - ...serverWebpackConfig, - devtool: 'something-besides-source-map', - }) as any, - }, - undefined, - { - sourcemaps: { - disable: true, - }, - }, - ); - - const finalWebpackConfig = finalNextConfig.webpack?.(serverWebpackConfig, serverBuildContext); - - expect(finalWebpackConfig?.devtool).not.toEqual('source-map'); - }); - - it('allows for the use of `hidden-source-map` as `devtool` value for client-side builds', async () => { - const finalClientWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig: exportedNextConfig, - incomingWebpackConfig: clientWebpackConfig, - incomingWebpackBuildContext: clientBuildContext, - sentryBuildTimeOptions: { hideSourceMaps: true }, - }); - - const finalServerWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig: exportedNextConfig, - incomingWebpackConfig: serverWebpackConfig, - incomingWebpackBuildContext: serverBuildContext, - sentryBuildTimeOptions: { hideSourceMaps: true }, - }); - - expect(finalClientWebpackConfig.devtool).toEqual('hidden-source-map'); - expect(finalServerWebpackConfig.devtool).toEqual('source-map'); - }); - - describe('webpack `entry` property config', () => { - const clientConfigFilePath = `./${CLIENT_SDK_CONFIG_FILE}`; - - it('injects user config file into `_app` in server bundle and in the client bundle', async () => { - const finalClientWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: clientWebpackConfig, - incomingWebpackBuildContext: clientBuildContext, - }); - - expect(finalClientWebpackConfig.entry).toEqual( - expect.objectContaining({ - 'pages/_app': expect.arrayContaining([clientConfigFilePath]), - }), - ); - }); - - it('does not inject anything into non-_app pages during client build', async () => { - const finalWebpackConfig = await materializeFinalWebpackConfig({ - exportedNextConfig, - incomingWebpackConfig: clientWebpackConfig, - incomingWebpackBuildContext: clientBuildContext, - }); - - expect(finalWebpackConfig.entry).toEqual({ - main: './src/index.ts', - // only _app has config file injected - 'pages/_app': ['./sentry.client.config.js', 'next-client-pages-loader?page=%2F_app'], - 'pages/_error': 'next-client-pages-loader?page=%2F_error', - 'pages/sniffTour': ['./node_modules/smellOVision/index.js', 'private-next-pages/sniffTour.js'], - 'pages/simulator/leaderboard': { - import: ['./node_modules/dogPoints/converter.js', 'private-next-pages/simulator/leaderboard.js'], - }, - simulatorBundle: './src/simulator/index.ts', - }); - }); - }); -}); diff --git a/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts b/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts deleted file mode 100644 index 177077d2b5c4..000000000000 --- a/packages/nextjs/test/config/webpack/webpackPluginOptions.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { BuildContext, NextConfigObject } from '../../../src/config/types'; -import { getWebpackPluginOptions } from '../../../src/config/webpackPluginOptions'; - -function generateBuildContext(overrides: { - dir?: string; - isServer: boolean; - nextjsConfig?: NextConfigObject; -}): BuildContext { - return { - dev: false, // The plugin is not included in dev mode - isServer: overrides.isServer, - buildId: 'test-build-id', - dir: overrides.dir ?? '/my/project/dir', - config: overrides.nextjsConfig ?? {}, - totalPages: 2, - defaultLoaders: true, - webpack: { - version: '4.0.0', - DefinePlugin: {} as any, - }, - }; -} - -describe('getWebpackPluginOptions()', () => { - it('forwards relevant options', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { - authToken: 'my-auth-token', - headers: { 'my-test-header': 'test' }, - org: 'my-org', - project: 'my-project', - telemetry: false, - reactComponentAnnotation: { - enabled: true, - }, - silent: false, - debug: true, - sentryUrl: 'my-url', - sourcemaps: { - assets: ['my-asset'], - ignore: ['my-ignore'], - }, - release: { - name: 'my-release', - create: false, - finalize: false, - dist: 'my-dist', - vcsRemote: 'my-origin', - setCommits: { - auto: true, - }, - deploy: { - env: 'my-env', - }, - }, - }); - - expect(generatedPluginOptions.authToken).toBe('my-auth-token'); - expect(generatedPluginOptions.debug).toBe(true); - expect(generatedPluginOptions.headers).toStrictEqual({ 'my-test-header': 'test' }); - expect(generatedPluginOptions.org).toBe('my-org'); - expect(generatedPluginOptions.project).toBe('my-project'); - expect(generatedPluginOptions.reactComponentAnnotation?.enabled).toBe(true); - expect(generatedPluginOptions.release?.create).toBe(false); - expect(generatedPluginOptions.release?.deploy?.env).toBe('my-env'); - expect(generatedPluginOptions.release?.dist).toBe('my-dist'); - expect(generatedPluginOptions.release?.finalize).toBe(false); - expect(generatedPluginOptions.release?.name).toBe('my-release'); - expect(generatedPluginOptions.release?.setCommits?.auto).toBe(true); - expect(generatedPluginOptions.release?.vcsRemote).toBe('my-origin'); - expect(generatedPluginOptions.silent).toBe(false); - expect(generatedPluginOptions.sourcemaps?.assets).toStrictEqual(['my-asset']); - expect(generatedPluginOptions.sourcemaps?.ignore).toStrictEqual(['my-ignore']); - expect(generatedPluginOptions.telemetry).toBe(false); - expect(generatedPluginOptions.url).toBe('my-url'); - - expect(generatedPluginOptions).toMatchObject({ - authToken: 'my-auth-token', - debug: true, - headers: { - 'my-test-header': 'test', - }, - org: 'my-org', - project: 'my-project', - reactComponentAnnotation: { - enabled: true, - }, - release: { - create: false, - deploy: { - env: 'my-env', - }, - dist: 'my-dist', - finalize: false, - inject: false, - name: 'my-release', - setCommits: { - auto: true, - }, - vcsRemote: 'my-origin', - }, - silent: false, - sourcemaps: { - assets: ['my-asset'], - ignore: ['my-ignore'], - }, - telemetry: false, - url: 'my-url', - }); - }); - - it('forwards bundleSizeOptimization options', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { - bundleSizeOptimizations: { - excludeTracing: true, - excludeReplayShadowDom: false, - }, - }); - - expect(generatedPluginOptions).toMatchObject({ - bundleSizeOptimizations: { - excludeTracing: true, - excludeReplayShadowDom: false, - }, - }); - }); - - it('returns the right `assets` and `ignore` values during the server build', () => { - const buildContext = generateBuildContext({ isServer: true }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['/my/project/dir/.next/server/**', '/my/project/dir/.next/serverless/**'], - ignore: [], - }); - }); - - it('returns the right `assets` and `ignore` values during the client build', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, {}); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['/my/project/dir/.next/static/chunks/pages/**', '/my/project/dir/.next/static/chunks/app/**'], - ignore: [ - '/my/project/dir/.next/static/chunks/framework-*', - '/my/project/dir/.next/static/chunks/framework.*', - '/my/project/dir/.next/static/chunks/main-*', - '/my/project/dir/.next/static/chunks/polyfills-*', - '/my/project/dir/.next/static/chunks/webpack-*', - ], - }); - }); - - it('returns the right `assets` and `ignore` values during the client build with `widenClientFileUpload`', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['/my/project/dir/.next/static/chunks/**'], - ignore: [ - '/my/project/dir/.next/static/chunks/framework-*', - '/my/project/dir/.next/static/chunks/framework.*', - '/my/project/dir/.next/static/chunks/main-*', - '/my/project/dir/.next/static/chunks/polyfills-*', - '/my/project/dir/.next/static/chunks/webpack-*', - ], - }); - }); - - it('sets `sourcemaps.assets` to an empty array when `sourcemaps.disable` is true', () => { - const buildContext = generateBuildContext({ isServer: false }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { sourcemaps: { disable: true } }); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: [], - }); - }); - - it('passes posix paths to the plugin', () => { - const buildContext = generateBuildContext({ - dir: 'C:\\my\\windows\\project\\dir', - nextjsConfig: { distDir: '.dist\\v1' }, - isServer: false, - }); - const generatedPluginOptions = getWebpackPluginOptions(buildContext, { widenClientFileUpload: true }); - expect(generatedPluginOptions.sourcemaps).toMatchObject({ - assets: ['C:/my/windows/project/dir/.dist/v1/static/chunks/**'], - ignore: [ - 'C:/my/windows/project/dir/.dist/v1/static/chunks/framework-*', - 'C:/my/windows/project/dir/.dist/v1/static/chunks/framework.*', - 'C:/my/windows/project/dir/.dist/v1/static/chunks/main-*', - 'C:/my/windows/project/dir/.dist/v1/static/chunks/polyfills-*', - 'C:/my/windows/project/dir/.dist/v1/static/chunks/webpack-*', - ], - }); - }); -}); diff --git a/packages/nextjs/test/config/withSentry.test.ts b/packages/nextjs/test/config/withSentry.test.ts deleted file mode 100644 index 30f34634d9fd..000000000000 --- a/packages/nextjs/test/config/withSentry.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import type { NextApiRequest, NextApiResponse } from 'next'; - -import type { AugmentedNextApiResponse, NextApiHandler } from '../../src/common/types'; -import { wrapApiHandlerWithSentry } from '../../src/server'; - -const startSpanManualSpy = jest.spyOn(SentryCore, 'startSpanManual'); - -describe('withSentry', () => { - let req: NextApiRequest, res: NextApiResponse; - - const origHandlerNoError: NextApiHandler = async (_req, res) => { - res.send('Good dog, Maisey!'); - }; - - const wrappedHandlerNoError = wrapApiHandlerWithSentry(origHandlerNoError, '/my-parameterized-route'); - - beforeEach(() => { - req = { url: 'http://dogs.are.great' } as NextApiRequest; - res = { - send: function (this: AugmentedNextApiResponse) { - this.end(); - }, - end: function (this: AugmentedNextApiResponse) { - // eslint-disable-next-line deprecation/deprecation - this.finished = true; - // @ts-expect-error This is a mock - this.writableEnded = true; - }, - } as unknown as AugmentedNextApiResponse; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('tracing', () => { - it('starts a transaction when tracing is enabled', async () => { - await wrappedHandlerNoError(req, res); - expect(startSpanManualSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'GET /my-parameterized-route', - op: 'http.server', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', - }, - }), - expect.any(Function), - ); - }); - }); -}); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts deleted file mode 100644 index e457174f5fa9..000000000000 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { defaultRuntimePhase, defaultsObject, exportedNextConfig, userNextConfig } from './fixtures'; -import { materializeFinalNextConfig } from './testUtils'; - -describe('withSentryConfig', () => { - it('includes expected properties', () => { - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig).toEqual( - expect.objectContaining({ - webpack: expect.any(Function), // `webpack` is tested specifically elsewhere - }), - ); - }); - - it('preserves unrelated next config options', () => { - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.publicRuntimeConfig).toEqual(userNextConfig.publicRuntimeConfig); - }); - - it("works when user's overall config is an object", () => { - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig).toEqual( - expect.objectContaining({ - ...userNextConfig, - webpack: expect.any(Function), // `webpack` is tested specifically elsewhere - }), - ); - }); - - it("works when user's overall config is a function", () => { - const exportedNextConfigFunction = () => userNextConfig; - - const finalConfig = materializeFinalNextConfig(exportedNextConfigFunction); - - expect(finalConfig).toEqual( - expect.objectContaining({ - ...exportedNextConfigFunction(), - webpack: expect.any(Function), // `webpack` is tested specifically elsewhere - }), - ); - }); - - it('correctly passes `phase` and `defaultConfig` through to functional `userNextConfig`', () => { - const exportedNextConfigFunction = jest.fn().mockReturnValue(userNextConfig); - - materializeFinalNextConfig(exportedNextConfigFunction); - - expect(exportedNextConfigFunction).toHaveBeenCalledWith(defaultRuntimePhase, defaultsObject); - }); -}); diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts deleted file mode 100644 index 284737f4335d..000000000000 --- a/packages/nextjs/test/config/wrappers.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'http'; -import * as SentryCore from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; - -import type { Client } from '@sentry/types'; -import { wrapGetInitialPropsWithSentry, wrapGetServerSidePropsWithSentry } from '../../src/common'; - -const startSpanManualSpy = jest.spyOn(SentryCore, 'startSpanManual'); - -describe('data-fetching function wrappers should create spans', () => { - const route = '/tricks/[trickName]'; - let req: IncomingMessage; - let res: ServerResponse; - - beforeEach(() => { - req = { headers: {}, url: 'http://dogs.are.great/tricks/kangaroo' } as IncomingMessage; - res = { end: jest.fn() } as unknown as ServerResponse; - - jest.spyOn(SentryCore, 'hasTracingEnabled').mockReturnValue(true); - jest.spyOn(SentryCore, 'getClient').mockImplementation(() => { - return { - getOptions: () => ({}), - getDsn: () => {}, - } as Client; - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('wrapGetServerSidePropsWithSentry', async () => { - const origFunction = jest.fn(async () => ({ props: {} })); - - const wrappedOriginal = wrapGetServerSidePropsWithSentry(origFunction, route); - await wrappedOriginal({ req, res } as any); - - expect(startSpanManualSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'getServerSideProps (/tricks/[trickName])', - op: 'function.nextjs', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }), - expect.any(Function), - ); - }); - - test('wrapGetInitialPropsWithSentry', async () => { - const origFunction = jest.fn(async () => ({})); - - const wrappedOriginal = wrapGetInitialPropsWithSentry(origFunction); - await wrappedOriginal({ req, res, pathname: route } as any); - - expect(startSpanManualSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'getInitialProps (/tricks/[trickName])', - op: 'function.nextjs', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - }, - }), - expect.any(Function), - ); - }); -}); diff --git a/packages/nextjs/test/config/wrappingLoader.test.ts b/packages/nextjs/test/config/wrappingLoader.test.ts deleted file mode 100644 index 4458a9ce16b6..000000000000 --- a/packages/nextjs/test/config/wrappingLoader.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const originalReadfileSync = fs.readFileSync; - -jest.spyOn(fs, 'readFileSync').mockImplementation((filePath, options) => { - if (filePath.toString().endsWith('/config/templates/apiWrapperTemplate.js')) { - return originalReadfileSync( - path.join(__dirname, '../../build/cjs/config/templates/apiWrapperTemplate.js'), - options, - ); - } - - if (filePath.toString().endsWith('/config/templates/pageWrapperTemplate.js')) { - return originalReadfileSync( - path.join(__dirname, '../../build/cjs/config/templates/pageWrapperTemplate.js'), - options, - ); - } - - if (filePath.toString().endsWith('/config/templates/middlewareWrapperTemplate.js')) { - return originalReadfileSync( - path.join(__dirname, '../../build/cjs/config/templates/middlewareWrapperTemplate.js'), - options, - ); - } - - if (filePath.toString().endsWith('/config/templates/sentryInitWrapperTemplate.js')) { - return originalReadfileSync( - path.join(__dirname, '../../build/cjs/config/templates/sentryInitWrapperTemplate.js'), - options, - ); - } - - if (filePath.toString().endsWith('/config/templates/serverComponentWrapperTemplate.js')) { - return originalReadfileSync( - path.join(__dirname, '../../build/cjs/config/templates/serverComponentWrapperTemplate.js'), - options, - ); - } - - if (filePath.toString().endsWith('/config/templates/routeHandlerWrapperTemplate.js')) { - return originalReadfileSync( - path.join(__dirname, '../../build/cjs/config/templates/routeHandlerWrapperTemplate.js'), - options, - ); - } - - return originalReadfileSync(filePath, options); -}); - -import type { LoaderThis } from '../../src/config/loaders/types'; -import type { WrappingLoaderOptions } from '../../src/config/loaders/wrappingLoader'; -import wrappingLoader from '../../src/config/loaders/wrappingLoader'; - -const DEFAULT_PAGE_EXTENSION_REGEX = ['tsx', 'ts', 'jsx', 'js'].join('|'); - -const defaultLoaderThis = { - addDependency: () => undefined, - async: () => undefined, - cacheable: () => undefined, -}; - -describe('wrappingLoader', () => { - it('should correctly wrap API routes on unix', async () => { - const callback = jest.fn(); - - const userCode = ` - export default function handler(req, res) { - res.json({ foo: "bar" }); - } - `; - const userCodeSourceMap = undefined; - - const loaderPromise = new Promise(resolve => { - const loaderThis = { - ...defaultLoaderThis, - resourcePath: '/my/pages/my/route.ts', - callback: callback.mockImplementation(() => { - resolve(); - }), - getOptions() { - return { - pagesDir: '/my/pages', - appDir: '/my/app', - pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, - excludeServerRoutes: [], - wrappingTargetKind: 'api-route', - vercelCronsConfig: undefined, - nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', - }; - }, - } satisfies LoaderThis; - - wrappingLoader.call(loaderThis, userCode, userCodeSourceMap); - }); - - await loaderPromise; - - expect(callback).toHaveBeenCalledWith(null, expect.stringContaining("'/my/route'"), expect.anything()); - }); -}); diff --git a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts b/packages/nextjs/test/edge/edgeWrapperUtils.test.ts deleted file mode 100644 index 029ee9d97fce..000000000000 --- a/packages/nextjs/test/edge/edgeWrapperUtils.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as coreSdk from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; - -import { withEdgeWrapping } from '../../src/common/utils/edgeWrapperUtils'; - -const origRequest = global.Request; -const origResponse = global.Response; - -// @ts-expect-error Request does not exist on type Global -global.Request = class Request { - headers = { - get() { - return null; - }, - }; -}; - -// @ts-expect-error Response does not exist on type Global -global.Response = class Request {}; - -afterAll(() => { - global.Request = origRequest; - global.Response = origResponse; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('withEdgeWrapping', () => { - it('should return a function that calls the passed function', async () => { - const origFunctionReturnValue = new Response(); - const origFunction = jest.fn(_req => origFunctionReturnValue); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - const returnValue = await wrappedFunction(new Request('https://sentry.io/')); - - expect(returnValue).toBe(origFunctionReturnValue); - expect(origFunction).toHaveBeenCalledTimes(1); - }); - - it('should return a function that calls captureException on error', async () => { - const captureExceptionSpy = jest.spyOn(coreSdk, 'captureException'); - const error = new Error(); - const origFunction = jest.fn(_req => { - throw error; - }); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - await expect(wrappedFunction(new Request('https://sentry.io/'))).rejects.toBe(error); - expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - }); - - it('should return a function that calls trace', async () => { - const startSpanSpy = jest.spyOn(coreSdk, 'startSpan'); - - const request = new Request('https://sentry.io/'); - const origFunction = jest.fn(_req => new Response()); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - await wrappedFunction(request); - - expect(startSpanSpy).toHaveBeenCalledTimes(1); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [coreSdk.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - name: 'some label', - op: 'some op', - }), - expect.any(Function), - ); - - expect(coreSdk.getIsolationScope().getScopeData().sdkProcessingMetadata).toEqual({ - request: { headers: {} }, - }); - }); - - it("should return a function that doesn't crash when req isn't passed", async () => { - const origFunctionReturnValue = new Response(); - const origFunction = jest.fn(() => origFunctionReturnValue); - - const wrappedFunction = withEdgeWrapping(origFunction, { - spanDescription: 'some label', - mechanismFunctionName: 'some name', - spanOp: 'some op', - }); - - await expect(wrappedFunction()).resolves.toBe(origFunctionReturnValue); - expect(origFunction).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts deleted file mode 100644 index 6e24eca21bfe..000000000000 --- a/packages/nextjs/test/edge/withSentryAPI.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as coreSdk from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; - -import { wrapApiHandlerWithSentry } from '../../src/edge'; - -const origRequest = global.Request; -const origResponse = global.Response; - -// @ts-expect-error Request does not exist on type Global -global.Request = class Request { - public url: string; - - public headers = { - get() { - return null; - }, - }; - - public method = 'POST'; - - public constructor(input: string) { - this.url = input; - } -}; - -// @ts-expect-error Response does not exist on type Global -global.Response = class Response {}; - -afterAll(() => { - global.Request = origRequest; - global.Response = origResponse; -}); - -const startSpanSpy = jest.spyOn(coreSdk, 'startSpan'); - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('wrapApiHandlerWithSentry', () => { - it('should return a function that calls trace', async () => { - const request = new Request('https://sentry.io/'); - const origFunction = jest.fn(_req => new Response()); - - const wrappedFunction = wrapApiHandlerWithSentry(origFunction, '/user/[userId]/post/[postId]'); - - await wrappedFunction(request); - - expect(startSpanSpy).toHaveBeenCalledTimes(1); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - name: 'POST /user/[userId]/post/[postId]', - op: 'http.server', - }), - expect.any(Function), - ); - }); - - it('should return a function that calls trace without throwing when no request is passed', async () => { - const origFunction = jest.fn(() => new Response()); - - const wrappedFunction = wrapApiHandlerWithSentry(origFunction, '/user/[userId]/post/[postId]'); - - await wrappedFunction(); - - expect(startSpanSpy).toHaveBeenCalledTimes(1); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [coreSdk.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.withEdgeWrapping', - }, - name: 'handler (/user/[userId]/post/[postId])', - op: 'http.server', - }), - expect.any(Function), - ); - }); -}); diff --git a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts deleted file mode 100644 index 16992a498f83..000000000000 --- a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { WINDOW } from '@sentry/react'; -import type { Client, HandlerDataFetch } from '@sentry/types'; -import * as sentryUtils from '@sentry/utils'; -import { JSDOM } from 'jsdom'; - -import { - appRouterInstrumentNavigation, - appRouterInstrumentPageLoad, -} from '../../src/client/routing/appRouterRoutingInstrumentation'; - -const addFetchInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); - -function setUpPage(url: string) { - const dom = new JSDOM('

nothingness

', { url }); - - // The Next.js routing instrumentations requires a few things to be present on pageload: - // 1. Access to window.document API for `window.document.getElementById` - // 2. Access to window.location API for `window.location.pathname` - Object.defineProperty(WINDOW, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(WINDOW, 'location', { value: dom.window.document.location, writable: true }); -} - -describe('appRouterInstrumentPageLoad', () => { - const originalGlobalDocument = WINDOW.document; - const originalGlobalLocation = WINDOW.location; - - afterEach(() => { - // Clean up JSDom - Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); - Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); - }); - - it('should create a pageload transactions with the current location name', () => { - setUpPage('https://example.com/some/page?someParam=foobar'); - - const emit = jest.fn(); - const client = { - emit, - } as unknown as Client; - - appRouterInstrumentPageLoad(client); - - expect(emit).toHaveBeenCalledTimes(1); - expect(emit).toHaveBeenCalledWith( - 'startPageLoadSpan', - expect.objectContaining({ - name: '/some/page', - attributes: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', - }, - }), - undefined, - ); - }); -}); - -describe('appRouterInstrumentNavigation', () => { - const originalGlobalDocument = WINDOW.document; - const originalGlobalLocation = WINDOW.location; - - afterEach(() => { - // Clean up JSDom - Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); - Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); - }); - - it('should create a navigation transactions when a navigation RSC request is sent', () => { - setUpPage('https://example.com/some/page?someParam=foobar'); - let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void; - - addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => { - fetchInstrumentationHandlerCallback = callback; - }); - - const emit = jest.fn(); - const client = { - emit, - } as unknown as Client; - - appRouterInstrumentNavigation(client); - - fetchInstrumentationHandlerCallback!({ - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: { - RSC: '1', - }, - }, - ], - fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }); - - expect(emit).toHaveBeenCalledTimes(1); - expect(emit).toHaveBeenCalledWith('startNavigationSpan', { - name: '/some/server/component/page', - attributes: { - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.nextjs.app_router_instrumentation', - 'sentry.source': 'url', - }, - }); - }); - - it.each([ - [ - 'no RSC header', - { - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: {}, - }, - ], - fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }, - ], - [ - 'no GET request', - { - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: { - RSC: '1', - }, - }, - ], - fetchData: { method: 'POST', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }, - ], - [ - 'prefetch request', - { - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: { - RSC: '1', - 'Next-Router-Prefetch': '1', - }, - }, - ], - fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }, - ], - ])( - 'should not create navigation transactions for fetch requests that are not navigating RSC requests (%s)', - (_, fetchCallbackData) => { - setUpPage('https://example.com/some/page?someParam=foobar'); - let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void; - - addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => { - fetchInstrumentationHandlerCallback = callback; - }); - - const emit = jest.fn(); - const client = { - emit, - } as unknown as Client; - - appRouterInstrumentNavigation(client); - fetchInstrumentationHandlerCallback!(fetchCallbackData); - - expect(emit).toHaveBeenCalledTimes(0); - }, - ); -}); diff --git a/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts b/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts deleted file mode 100644 index 947b3c9f3f69..000000000000 --- a/packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { WINDOW } from '@sentry/react'; -import type { Client } from '@sentry/types'; -import { JSDOM } from 'jsdom'; -import type { NEXT_DATA } from 'next/dist/shared/lib/utils'; -import Router from 'next/router'; - -import { - pagesRouterInstrumentNavigation, - pagesRouterInstrumentPageLoad, -} from '../../src/client/routing/pagesRouterRoutingInstrumentation'; - -const globalObject = WINDOW as typeof WINDOW & { - __BUILD_MANIFEST?: { - sortedPages?: string[]; - }; -}; - -const originalBuildManifest = globalObject.__BUILD_MANIFEST; -const originalBuildManifestRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; - -let eventHandlers: { [eventName: string]: Set<(...args: any[]) => void> } = {}; - -jest.mock('next/router', () => { - return { - default: { - events: { - on(type: string, handler: (...args: any[]) => void) { - if (!eventHandlers[type]) { - eventHandlers[type] = new Set(); - } - - eventHandlers[type]!.add(handler); - }, - off: jest.fn((type: string, handler: (...args: any[]) => void) => { - if (eventHandlers[type]) { - eventHandlers[type]!.delete(handler); - } - }), - emit(type: string, ...eventArgs: any[]) { - if (eventHandlers[type]) { - eventHandlers[type]!.forEach(eventHandler => { - eventHandler(...eventArgs); - }); - } - }, - }, - }, - }; -}); - -describe('pagesRouterInstrumentPageLoad', () => { - const originalGlobalDocument = WINDOW.document; - const originalGlobalLocation = WINDOW.location; - - function setUpNextPage(pageProperties: { - url: string; - route: string; - query?: any; - props?: any; - navigatableRoutes?: string[]; - hasNextData: boolean; - }) { - const nextDataContent: NEXT_DATA = { - props: pageProperties.props, - page: pageProperties.route, - query: pageProperties.query, - buildId: 'y76hvndNJBAithejdVGLW', - isFallback: false, - gssp: true, - appGip: true, - scriptLoader: [], - }; - - const dom = new JSDOM( - // Just an example of what a __NEXT_DATA__ tag might look like - pageProperties.hasNextData - ? `` - : '

No next data :(

', - { url: pageProperties.url }, - ); - - // The Next.js routing instrumentations requires a few things to be present on pageload: - // 1. Access to window.document API for `window.document.getElementById` - // 2. Access to window.location API for `window.location.pathname` - Object.defineProperty(WINDOW, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(WINDOW, 'location', { value: dom.window.document.location, writable: true }); - - // Define Next.js clientside build manifest with navigatable routes - globalObject.__BUILD_MANIFEST = { - ...globalObject.__BUILD_MANIFEST, - sortedPages: pageProperties.navigatableRoutes as string[], - }; - } - - afterEach(() => { - // Clean up JSDom - Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); - Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); - - // Reset Next.js' __BUILD_MANIFEST - globalObject.__BUILD_MANIFEST = originalBuildManifest; - if (globalObject.__BUILD_MANIFEST) { - globalObject.__BUILD_MANIFEST.sortedPages = originalBuildManifestRoutes as string[]; - } - - // Clear all event handlers - eventHandlers = {}; - - // Necessary to clear all Router.events.off() mock call numbers - jest.clearAllMocks(); - }); - - it.each([ - [ - 'https://example.com/lforst/posts/1337?q=42', - '/[user]/posts/[id]', - { user: 'lforst', id: '1337', q: '42' }, - { - pageProps: { - _sentryTraceData: 'c82b8554881b4d28ad977de04a4fb40a-a755953cd3394d5f-1', - _sentryBaggage: 'other=vendor,foo=bar,third=party,last=item,sentry-release=2.1.0,sentry-environment=myEnv', - }, - }, - true, - { - name: '/[user]/posts/[id]', - attributes: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', - 'sentry.source': 'route', - }, - }, - ], - [ - 'https://example.com/some-page', - '/some-page', - {}, - { - pageProps: { - _sentryTraceData: 'c82b8554881b4d28ad977de04a4fb40a-a755953cd3394d5f-1', - _sentryBaggage: 'other=vendor,foo=bar,third=party,last=item,sentry-release=2.1.0,sentry-environment=myEnv', - }, - }, - true, - { - name: '/some-page', - attributes: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', - 'sentry.source': 'route', - }, - }, - ], - [ - 'https://example.com/', - '/', - {}, - {}, - true, - { - name: '/', - attributes: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', - 'sentry.source': 'route', - }, - }, - ], - [ - 'https://example.com/lforst/posts/1337?q=42', - '/', - {}, - {}, - false, // no __NEXT_DATA__ tag - { - name: '/lforst/posts/1337', - attributes: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', - 'sentry.source': 'url', - }, - }, - ], - ])( - 'creates a pageload transaction (#%#)', - (url, route, query, props, hasNextData, expectedStartTransactionArgument) => { - setUpNextPage({ url, route, query, props, hasNextData }); - - const emit = jest.fn(); - const client = { - emit, - getOptions: () => ({}), - } as unknown as Client; - - pagesRouterInstrumentPageLoad(client); - - const sentryTrace = (props as any).pageProps?._sentryTraceData; - const baggage = (props as any).pageProps?._sentryBaggage; - - expect(emit).toHaveBeenCalledTimes(1); - expect(emit).toHaveBeenCalledWith( - 'startPageLoadSpan', - expect.objectContaining(expectedStartTransactionArgument), - { - sentryTrace, - baggage, - }, - ); - }, - ); -}); - -describe('pagesRouterInstrumentNavigation', () => { - const originalGlobalDocument = WINDOW.document; - const originalGlobalLocation = WINDOW.location; - - function setUpNextPage(pageProperties: { - url: string; - route: string; - query?: any; - props?: any; - navigatableRoutes?: string[]; - hasNextData: boolean; - }) { - const nextDataContent: NEXT_DATA = { - props: pageProperties.props, - page: pageProperties.route, - query: pageProperties.query, - buildId: 'y76hvndNJBAithejdVGLW', - isFallback: false, - gssp: true, - appGip: true, - scriptLoader: [], - }; - - const dom = new JSDOM( - // Just an example of what a __NEXT_DATA__ tag might look like - pageProperties.hasNextData - ? `` - : '

No next data :(

', - { url: pageProperties.url }, - ); - - // The Next.js routing instrumentations requires a few things to be present on pageload: - // 1. Access to window.document API for `window.document.getElementById` - // 2. Access to window.location API for `window.location.pathname` - Object.defineProperty(WINDOW, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(WINDOW, 'location', { value: dom.window.document.location, writable: true }); - - // Define Next.js clientside build manifest with navigatable routes - globalObject.__BUILD_MANIFEST = { - ...globalObject.__BUILD_MANIFEST, - sortedPages: pageProperties.navigatableRoutes as string[], - }; - } - - afterEach(() => { - // Clean up JSDom - Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); - Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); - - // Reset Next.js' __BUILD_MANIFEST - globalObject.__BUILD_MANIFEST = originalBuildManifest; - if (globalObject.__BUILD_MANIFEST) { - globalObject.__BUILD_MANIFEST.sortedPages = originalBuildManifestRoutes as string[]; - } - - // Clear all event handlers - eventHandlers = {}; - - // Necessary to clear all Router.events.off() mock call numbers - jest.clearAllMocks(); - }); - - it.each([ - ['/news', '/news', 'route'], - ['/news/', '/news', 'route'], - ['/some-route-that-is-not-defined-12332', '/some-route-that-is-not-defined-12332', 'url'], // unknown route - ['/some-route-that-is-not-defined-12332?q=42', '/some-route-that-is-not-defined-12332', 'url'], // unknown route w/ query param - ['/posts/42', '/posts/[id]', 'route'], - ['/posts/42/', '/posts/[id]', 'route'], - ['/posts/42?someParam=1', '/posts/[id]', 'route'], // query params are ignored - ['/posts/42/details', '/posts/[id]/details', 'route'], - ['/users/1337/friends/closeby/good', '/users/[id]/friends/[...filters]', 'route'], - ['/users/1337/friends', '/users/1337/friends', 'url'], - ['/statistics/page-visits', '/statistics/[[...parameters]]', 'route'], - ['/statistics', '/statistics/[[...parameters]]', 'route'], - ['/a/b/c/d', '/[a]/b/[c]/[...d]', 'route'], - ['/a/b/c/d/e', '/[a]/b/[c]/[...d]', 'route'], - ['/a/b/c', '/a/b/c', 'url'], - ['/e/f/g/h', '/e/[f]/[g]/[[...h]]', 'route'], - ['/e/f/g/h/i', '/e/[f]/[g]/[[...h]]', 'route'], - ['/e/f/g', '/e/[f]/[g]/[[...h]]', 'route'], - ])( - 'should create a parameterized transaction on route change (%s)', - (targetLocation, expectedTransactionName, expectedTransactionSource) => { - setUpNextPage({ - url: 'https://example.com/home', - route: '/home', - hasNextData: true, - navigatableRoutes: [ - '/home', - '/news', - '/posts/[id]', - '/posts/[id]/details', - '/users/[id]/friends/[...filters]', - '/statistics/[[...parameters]]', - // just some complicated routes to see if we get the matching right - '/[a]/b/[c]/[...d]', - '/e/[f]/[g]/[[...h]]', - ], - }); - - const emit = jest.fn(); - const client = { - emit, - getOptions: () => ({}), - } as unknown as Client; - - pagesRouterInstrumentNavigation(client); - - Router.events.emit('routeChangeStart', targetLocation); - - expect(emit).toHaveBeenCalledTimes(1); - expect(emit).toHaveBeenCalledWith( - 'startNavigationSpan', - expect.objectContaining({ - name: expectedTransactionName, - attributes: { - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.nextjs.pages_router_instrumentation', - 'sentry.source': expectedTransactionSource, - }, - }), - ); - }, - ); -}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts deleted file mode 100644 index 27230874d457..000000000000 --- a/packages/nextjs/test/serverSdk.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { getCurrentScope } from '@sentry/node'; -import * as SentryNode from '@sentry/node'; -import type { Integration } from '@sentry/types'; -import { GLOBAL_OBJ } from '@sentry/utils'; - -import { init } from '../src/server'; - -// normally this is set as part of the build process, so mock it here -(GLOBAL_OBJ as typeof GLOBAL_OBJ & { __rewriteFramesDistDir__: string }).__rewriteFramesDistDir__ = '.next'; - -const nodeInit = jest.spyOn(SentryNode, 'init'); - -function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { - return integrations.find(integration => integration.name === name); -} - -describe('Server init()', () => { - afterEach(() => { - jest.clearAllMocks(); - - SentryNode.getGlobalScope().clear(); - SentryNode.getIsolationScope().clear(); - SentryNode.getCurrentScope().clear(); - SentryNode.getCurrentScope().setClient(undefined); - - delete process.env.VERCEL; - }); - - it('inits the Node SDK', () => { - expect(nodeInit).toHaveBeenCalledTimes(0); - init({}); - expect(nodeInit).toHaveBeenCalledTimes(1); - expect(nodeInit).toHaveBeenLastCalledWith( - expect.objectContaining({ - _metadata: { - sdk: { - name: 'sentry.javascript.nextjs', - version: expect.any(String), - packages: [ - { - name: 'npm:@sentry/nextjs', - version: expect.any(String), - }, - { - name: 'npm:@sentry/node', - version: expect.any(String), - }, - ], - }, - }, - autoSessionTracking: false, - environment: 'test', - - // Integrations are tested separately, and we can't be more specific here without depending on the order in - // which integrations appear in the array, which we can't guarantee. - // - // TODO: If we upgrde to Jest 28+, we can follow Jest's example matcher and create an - // `expect.ArrayContainingInAnyOrder`. See - // https://github.com/facebook/jest/blob/main/examples/expect-extend/toBeWithinRange.ts. - defaultIntegrations: expect.any(Array), - }), - ); - }); - - it("doesn't reinitialize the node SDK if already initialized", () => { - expect(nodeInit).toHaveBeenCalledTimes(0); - init({}); - expect(nodeInit).toHaveBeenCalledTimes(1); - init({}); - expect(nodeInit).toHaveBeenCalledTimes(1); - }); - - // TODO: test `vercel` tag when running on Vercel - // Can't just add the test and set env variables, since the value in `index.server.ts` - // is resolved when importing. - - it('does not apply `vercel` tag when not running on vercel', () => { - const currentScope = getCurrentScope(); - - expect(process.env.VERCEL).toBeUndefined(); - - init({}); - - // @ts-expect-error need access to protected _tags attribute - expect(currentScope._tags.vercel).toBeUndefined(); - }); - - describe('integrations', () => { - // Options passed by `@sentry/nextjs`'s `init` to `@sentry/node`'s `init` after modifying them - type ModifiedInitOptions = { integrations: Integration[]; defaultIntegrations: Integration[] }; - - it('adds default integrations', () => { - init({}); - - const nodeInitOptions = nodeInit.mock.calls[0]?.[0] as ModifiedInitOptions; - const integrationNames = nodeInitOptions.defaultIntegrations.map(integration => integration.name); - const onUncaughtExceptionIntegration = findIntegrationByName( - nodeInitOptions.defaultIntegrations, - 'OnUncaughtException', - ); - - expect(integrationNames).toContain('DistDirRewriteFrames'); - expect(onUncaughtExceptionIntegration).toBeDefined(); - }); - - it('supports passing unrelated integrations through options', () => { - init({ integrations: [SentryNode.consoleIntegration()] }); - - const nodeInitOptions = nodeInit.mock.calls[0]?.[0] as ModifiedInitOptions; - const consoleIntegration = findIntegrationByName(nodeInitOptions.integrations, 'Console'); - - expect(consoleIntegration).toBeDefined(); - }); - }); - - it('returns client from init', () => { - expect(init({})).not.toBeUndefined(); - }); -}); diff --git a/packages/nextjs/test/tsconfig.json b/packages/nextjs/test/tsconfig.json deleted file mode 100644 index 5c0c9dfa01bb..000000000000 --- a/packages/nextjs/test/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../tsconfig.test.json", - - "include": ["./**/*", "../playwright.config.ts"] -} diff --git a/packages/nextjs/test/types/.gitignore b/packages/nextjs/test/types/.gitignore deleted file mode 100644 index 23d67fc10447..000000000000 --- a/packages/nextjs/test/types/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -yarn.lock diff --git a/packages/nextjs/test/types/next.config.ts b/packages/nextjs/test/types/next.config.ts deleted file mode 100644 index 74ea16a946db..000000000000 --- a/packages/nextjs/test/types/next.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { NextConfig } from 'next'; - -import { withSentryConfig } from '../../src/config/withSentryConfig'; - -const config: NextConfig = { - hideSourceMaps: true, - webpack: config => ({ - ...config, - module: { - ...config.module, - rules: [...config.module.rules], - }, - }), -}; - -module.exports = withSentryConfig(config); diff --git a/packages/nextjs/test/types/package.json b/packages/nextjs/test/types/package.json deleted file mode 100644 index 86f74bfe060a..000000000000 --- a/packages/nextjs/test/types/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "description": "This is used to install the nextjs v12 so we can test against those types", - "scripts": { - "test": "ts-node test.ts" - }, - "dependencies": { - "next": "13.2.0" - } -} diff --git a/packages/nextjs/test/types/test.ts b/packages/nextjs/test/types/test.ts deleted file mode 100644 index d9b5f059958c..000000000000 --- a/packages/nextjs/test/types/test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { execSync } from 'child_process'; -/* eslint-disable no-console */ -import { parseSemver } from '@sentry/utils'; - -const NODE_VERSION = parseSemver(process.versions.node); - -if (NODE_VERSION.major && NODE_VERSION.major >= 12) { - console.log('Installing next@v12...'); - execSync('yarn install', { stdio: 'inherit' }); - console.log('Testing some types...'); - execSync('tsc --noEmit --project tsconfig.json', { stdio: 'inherit' }); -} diff --git a/packages/nextjs/test/types/tsconfig.json b/packages/nextjs/test/types/tsconfig.json deleted file mode 100644 index adedc2fafa6c..000000000000 --- a/packages/nextjs/test/types/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": [ - "./**/*" - ] -} diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts deleted file mode 100644 index 576898c061b2..000000000000 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { BrowserOptions } from '@sentry/react'; - -import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; - -const globalWithInjectedValues = global as typeof global & { - __sentryRewritesTunnelPath__?: string; -}; - -beforeEach(() => { - globalWithInjectedValues.__sentryRewritesTunnelPath__ = undefined; -}); - -describe('applyTunnelRouteOption()', () => { - it('Correctly applies `tunnelRoute` option when conditions are met', () => { - globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333'); - }); - - it("Doesn't apply `tunnelRoute` when DSN is missing", () => { - globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route'; - const options: any = { - // no dsn - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { - globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route'; - const options: any = { - dsn: 'invalidDsn', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - it("Doesn't apply `tunnelRoute` option when `tunnelRoute` option wasn't injected", () => { - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - it("Doesn't `tunnelRoute` option when DSN is not a SaaS DSN", () => { - globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@example.com/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - it('Correctly applies `tunnelRoute` option to region DSNs', () => { - globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333&r=us'); - }); -}); diff --git a/packages/nextjs/tsconfig.json b/packages/nextjs/tsconfig.json deleted file mode 100644 index bf45a09f2d71..000000000000 --- a/packages/nextjs/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.json", - - "include": ["src/**/*"], - - "compilerOptions": { - // package-specific options - } -} diff --git a/packages/nextjs/tsconfig.test.json b/packages/nextjs/tsconfig.test.json deleted file mode 100644 index f72f7d93a39e..000000000000 --- a/packages/nextjs/tsconfig.test.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "./tsconfig.json", - - "include": ["test/**/*"], - - "compilerOptions": { - // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["node", "jest"], - - // other package-specific, test-specific options - "lib": ["DOM", "ESNext"] - } -} diff --git a/packages/nextjs/tsconfig.types.json b/packages/nextjs/tsconfig.types.json deleted file mode 100644 index 978b51b8e126..000000000000 --- a/packages/nextjs/tsconfig.types.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "./tsconfig.json", - - // Some of the templates for code we inject into a user's app include an import from `@sentry/nextjs`. This makes - // creating types for these template files a circular exercise, which causes `tsc` to crash. Fortunately, since the - // templates aren't consumed as modules (they're essentially just text files which happen to contain code), we don't - // actually need to create types for them. - "exclude": ["src/config/templates/*"], - - "compilerOptions": { - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": true, - "outDir": "build/types" - } -}