diff --git a/.craft.yml b/.craft.yml
index d387e917307d..185fa2fd0510 100644
--- a/.craft.yml
+++ b/.craft.yml
@@ -114,6 +114,9 @@ targets:
- name: npm
id: '@sentry/remix'
includeNames: /^sentry-remix-\d.*\.tgz$/
+ - name: npm
+ id: '@sentry/solidstart'
+ includeNames: /^sentry-solidstart-\d.*\.tgz$/
- name: npm
id: '@sentry/sveltekit'
includeNames: /^sentry-sveltekit-\d.*\.tgz$/
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 437191889954..43e4e82c61b0 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -1,6 +1,6 @@
name: 🐞 Bug Report
description: Tell us about something that's not working the way we (probably) intend.
-labels: ['Type: Bug']
+type: 'bug'
body:
- type: checkboxes
attributes:
@@ -31,20 +31,23 @@ body:
setup.
options:
- '@sentry/browser'
- - '@sentry/astro'
+ - '@sentry/node'
- '@sentry/angular'
+ - '@sentry/astro'
- '@sentry/aws-serverless'
- '@sentry/bun'
+ - '@sentry/cloudflare'
- '@sentry/deno'
- '@sentry/ember'
- '@sentry/gatsby'
- '@sentry/google-cloud-serverless'
- '@sentry/nestjs'
- '@sentry/nextjs'
- - '@sentry/node'
+ - '@sentry/nuxt'
- '@sentry/react'
- '@sentry/remix'
- '@sentry/solid'
+ - '@sentry/solidstart'
- '@sentry/svelte'
- '@sentry/sveltekit'
- '@sentry/vue'
diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml
index 7749deee1d44..185de4888de1 100644
--- a/.github/ISSUE_TEMPLATE/feature.yml
+++ b/.github/ISSUE_TEMPLATE/feature.yml
@@ -1,6 +1,6 @@
name: 💡 Feature Request
description: Create a feature request for a sentry-javascript SDK.
-labels: ['Type: Improvement']
+type: 'enhancement'
body:
- type: markdown
attributes:
diff --git a/.github/ISSUE_TEMPLATE/flaky.yml b/.github/ISSUE_TEMPLATE/flaky.yml
index 48e6ce41c047..a679cf98d328 100644
--- a/.github/ISSUE_TEMPLATE/flaky.yml
+++ b/.github/ISSUE_TEMPLATE/flaky.yml
@@ -1,6 +1,7 @@
name: ❅ Flaky Test
description: Report a flaky test in CI
title: '[Flaky CI]: '
+type: 'task'
labels: ['Type: Tests']
body:
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/internal.yml b/.github/ISSUE_TEMPLATE/internal.yml
index bd5b1d1f1970..308d04db7eb5 100644
--- a/.github/ISSUE_TEMPLATE/internal.yml
+++ b/.github/ISSUE_TEMPLATE/internal.yml
@@ -1,6 +1,10 @@
name: 💡 [Internal] Blank Issue
description: Only for Sentry Employees! Create an issue without a template.
+type: 'task'
body:
+ - type: markdown
+ attributes:
+ value: Make sure to apply relevant labels and issue types before submitting.
- type: textarea
id: description
attributes:
diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml
index 29ecbcfbd2d1..7f85f5e743ba 100644
--- a/.github/actions/install-playwright/action.yml
+++ b/.github/actions/install-playwright/action.yml
@@ -1,5 +1,9 @@
name: "Install Playwright dependencies"
description: "Installs Playwright dependencies and caches them."
+inputs:
+ browsers:
+ description: 'What browsers to install.'
+ default: 'chromium webkit firefox'
runs:
using: "composite"
@@ -17,12 +21,13 @@ runs:
~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+ # We always install all browsers, if uncached
- name: Install Playwright dependencies (uncached)
run: npx playwright install chromium webkit firefox --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
- name: Install Playwright system dependencies only (cached)
- run: npx playwright install-deps chromium webkit firefox
+ run: npx playwright install-deps ${{ inputs.browsers || 'chromium webkit firefox' }}
if: steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6eea92c884ef..8ab03a313253 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -528,7 +528,7 @@ jobs:
run: yarn lerna run test --scope @sentry/profiling-node
job_browser_playwright_tests:
- name: Playwright (${{ matrix.bundle }}${{ matrix.shard && format(' {0}/{1}', matrix.shard, matrix.shards) || ''}}) Tests
+ name: Playwright ${{ matrix.bundle }}${{ matrix.project && matrix.project != 'chromium' && format(' {0}', matrix.project) || ''}}${{ matrix.shard && format(' ({0}/{1})', matrix.shard, matrix.shards) || ''}} Tests
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request'
runs-on: ubuntu-20.04-large-js
@@ -548,31 +548,30 @@ jobs:
project:
- chromium
include:
- # Only check all projects for esm & full bundle
+ # Only check all projects for full bundle
# We also shard the tests as they take the longest
- bundle: bundle_tracing_replay_feedback_min
- project: ''
- shard: 1
- shards: 2
+ project: 'webkit'
- bundle: bundle_tracing_replay_feedback_min
- project: ''
- shard: 2
- shards: 2
+ project: 'firefox'
- bundle: esm
- project: ''
+ project: chromium
shard: 1
- shards: 3
+ shards: 4
- bundle: esm
+ project: chromium
shard: 2
- shards: 3
+ shards: 4
- bundle: esm
- project: ''
+ project: chromium
shard: 3
- shards: 3
+ shards: 4
+ - bundle: esm
+ project: chromium
+ shard: 4
+ shards: 4
exclude:
- # Do not run the default chromium-only tests
- - bundle: bundle_tracing_replay_feedback_min
- project: 'chromium'
+ # Do not run the un-sharded esm tests
- bundle: esm
project: 'chromium'
@@ -592,12 +591,15 @@ jobs:
- name: Install Playwright
uses: ./.github/actions/install-playwright
+ with:
+ browsers: ${{ matrix.project }}
- name: Run Playwright tests
env:
PW_BUNDLE: ${{ matrix.bundle }}
working-directory: dev-packages/browser-integration-tests
run: yarn test:ci${{ matrix.project && format(' --project={0}', matrix.project) || '' }}${{ matrix.shard && format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }}
+
- name: Upload Playwright Traces
uses: actions/upload-artifact@v3
if: always()
@@ -606,7 +608,7 @@ jobs:
path: dev-packages/browser-integration-tests/test-results
job_browser_loader_tests:
- name: Playwright Loader (${{ matrix.bundle }}) Tests
+ name: PW ${{ matrix.bundle }} Tests
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request'
runs-on: ubuntu-20.04
@@ -639,6 +641,8 @@ jobs:
- name: Install Playwright
uses: ./.github/actions/install-playwright
+ with:
+ browsers: chromium
- name: Run Playwright Loader tests
env:
@@ -750,8 +754,12 @@ jobs:
uses: ./.github/actions/restore-cache
env:
DEPENDENCY_CACHE_KEY: ${{ needs.job_build.outputs.dependency_cache_key }}
+
- name: Install Playwright
uses: ./.github/actions/install-playwright
+ with:
+ browsers: chromium
+
- name: Run integration tests
env:
NODE_VERSION: ${{ matrix.node }}
@@ -878,6 +886,7 @@ jobs:
'react-router-5',
'react-router-6',
'solid',
+ 'solidstart',
'svelte-5',
'sveltekit',
'sveltekit-2',
@@ -952,6 +961,8 @@ jobs:
- name: Install Playwright
uses: ./.github/actions/install-playwright
+ with:
+ browsers: chromium
- name: Get node version
id: versions
@@ -1049,6 +1060,8 @@ jobs:
- name: Install Playwright
uses: ./.github/actions/install-playwright
+ with:
+ browsers: chromium
- name: Get node version
id: versions
@@ -1149,6 +1162,8 @@ jobs:
- name: Install Playwright
uses: ./.github/actions/install-playwright
+ with:
+ browsers: chromium
- name: Get node version
id: versions
diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml
index d2238ce58e71..5d0d5af5d247 100644
--- a/.github/workflows/flaky-test-detector.yml
+++ b/.github/workflows/flaky-test-detector.yml
@@ -68,10 +68,10 @@ jobs:
CHANGED_TEST_PATHS: ${{ steps.changed.outputs.browser_integration_files }}
TEST_RUN_COUNT: 'AUTO'
- - name: Artifacts upload
+ - name: Upload Playwright Traces
uses: actions/upload-artifact@v4
if: failure() && steps.test.outcome == 'failure'
with:
name: playwright-test-results
- path: test-results
+ path: dev-packages/browser-integration-tests/test-results
retention-days: 5
diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml
index 2e2f41904f7c..ad5edbd3b398 100644
--- a/.github/workflows/issue-package-label.yml
+++ b/.github/workflows/issue-package-label.yml
@@ -29,17 +29,26 @@ jobs:
# Note: Since this is handled as a regex, and JSON parse wrangles slashes /, we just use `.` instead
map: |
{
+ "@sentry.angular": {
+ "label": "Package: angular"
+ },
"@sentry.astro": {
- "label": "Package: Astro"
+ "label": "Package: astro"
},
- "@sentry.browser": {
- "label": "Package: Browser"
+ "@sentry.aws-serverless": {
+ "label": "Package: aws-serverless"
},
- "@sentry.angular": {
- "label": "Package: Angular"
+ "@sentry.browser": {
+ "label": "Package: browser"
},
"@sentry.bun": {
- "label": "Package: Bun"
+ "label": "Package: bun"
+ },
+ "@sentry.cloudflare": {
+ "label": "Package: cloudflare"
+ },
+ "@sentry.deno": {
+ "label": "Package: deno"
},
"@sentry.ember": {
"label": "Package: ember"
@@ -47,11 +56,20 @@ jobs:
"@sentry.gatsby": {
"label": "Package: gatbsy"
},
+ "@sentry.google-cloud-serverless": {
+ "label": "Package: google-cloud-serverless"
+ },
+ "@sentry.nestjs": {
+ "label": "Package: nestjs"
+ },
"@sentry.nextjs": {
- "label": "Package: Nextjs"
+ "label": "Package: nextjs"
},
"@sentry.node": {
- "label": "Package: Node"
+ "label": "Package: node"
+ },
+ "@sentry.nuxt": {
+ "label": "Package: nuxt"
},
"@sentry.react": {
"label": "Package: react"
@@ -59,15 +77,18 @@ jobs:
"@sentry.remix": {
"label": "Package: remix"
},
- "@sentry.serverless": {
- "label": "Package: Serverless"
+ "@sentry.solid": {
+ "label": "Package: solid"
},
- "@sentry.sveltekit": {
- "label": "Package: SvelteKit"
+ "@sentry.solid": {
+ "label": "Package: solidstart"
},
"@sentry.svelte": {
"label": "Package: svelte"
},
+ "@sentry.sveltekit": {
+ "label": "Package: sveltekit"
+ },
"@sentry.vue": {
"label": "Package: vue"
},
diff --git a/.size-limit.js b/.size-limit.js
index 72050f7225f3..437e466a89e1 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -22,7 +22,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
- limit: '72 KB',
+ limit: '73 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ad5a1920aae..2e8d141efd95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,28 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 8.25.0
+
+### Important Changes
+
+- **Alpha release of Official Solid Start SDK**
+
+This release contains the alpha version of `@sentry/solidstart`, our SDK for [Solid Start](https://start.solidjs.com/)!
+For details on how to use it, please see the [README](./packages/solidstart/README.md). Any feedback/bug reports are
+greatly appreciated, please [reach out on GitHub](https://github.com/getsentry/sentry-javascript/issues/12538).
+
+### Other Changes
+
+- feat(astro): Add `bundleSizeOptimizations` vite options to integration (#13250)
+- feat(astro): Always add BrowserTracing (#13244)
+- feat(core): Add `getTraceMetaTags` function (#13201)
+- feat(nestjs): Automatic instrumentation of nestjs exception filters (#13230)
+- feat(node): Add `useOperationNameForRootSpan` to`graphqlIntegration` (#13248)
+- feat(sveltekit): Add `wrapServerRouteWithSentry` wrapper (#13247)
+- fix(aws-serverless): Extract sentry trace data from handler `context` over `event` (#13266)
+- fix(browser): Initialize default integration if `defaultIntegrations: undefined` (#13261)
+- fix(utils): Streamline IP capturing on incoming requests (#13272)
+
## 8.24.0
- feat(nestjs): Filter RPC exceptions (#13227)
diff --git a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
index ba3dd43ac3d3..aafdced81505 100644
--- a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
+++ b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts
@@ -18,49 +18,51 @@ sentryTest('mutation after threshold results in slow click', async ({ forceFlush
const url = await getLocalTestUrl({ testDir: __dirname });
- await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]);
- await forceFlushReplay();
+ const replayRequestPromise = waitForReplayRequest(page, 0);
+
+ const segmentReqWithSlowClickBreadcrumbPromise = waitForReplayRequest(page, (event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
+
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
+ });
- const [req1] = await Promise.all([
- waitForReplayRequest(page, (event, res) => {
- const { breadcrumbs } = getCustomRecordingEvents(res);
+ await page.goto(url);
+ await replayRequestPromise;
- return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
- }),
+ await forceFlushReplay();
+
+ await page.locator('#mutationButton').click();
- page.locator('#mutationButton').click(),
- ]);
+ const segmentReqWithSlowClick = await segmentReqWithSlowClickBreadcrumbPromise;
- const { breadcrumbs } = getCustomRecordingEvents(req1);
+ const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithSlowClick);
const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
- expect(slowClickBreadcrumbs).toEqual([
- {
- category: 'ui.slowClickDetected',
- type: 'default',
- data: {
- endReason: 'mutation',
- clickCount: 1,
- node: {
- attributes: {
- id: 'mutationButton',
- },
- id: expect.any(Number),
- tagName: 'button',
- textContent: '******* ********',
+ expect(slowClickBreadcrumbs).toContainEqual({
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ endReason: 'mutation',
+ clickCount: 1,
+ node: {
+ attributes: {
+ id: 'mutationButton',
},
- nodeId: expect.any(Number),
- timeAfterClickMs: expect.any(Number),
- url: 'http://sentry-test.io/index.html',
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ********',
},
- message: 'body > button#mutationButton',
- timestamp: expect.any(Number),
+ nodeId: expect.any(Number),
+ timeAfterClickMs: expect.any(Number),
+ url: 'http://sentry-test.io/index.html',
},
- ]);
+ message: 'body > button#mutationButton',
+ timestamp: expect.any(Number),
+ });
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
- expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3500);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3501);
});
sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => {
@@ -78,49 +80,50 @@ sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });
- await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]);
+ const replayRequestPromise = waitForReplayRequest(page, 0);
+ const segmentReqWithSlowClickBreadcrumbPromise = waitForReplayRequest(page, (event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
- const [req1] = await Promise.all([
- waitForReplayRequest(page, (event, res) => {
- const { breadcrumbs } = getCustomRecordingEvents(res);
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
+ });
- return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
- }),
- page.locator('#mutationButton').click({ clickCount: 4 }),
- ]);
+ await page.goto(url);
+ await replayRequestPromise;
- const { breadcrumbs } = getCustomRecordingEvents(req1);
+ await page.locator('#mutationButton').click({ clickCount: 4 });
+
+ const segmentReqWithSlowClick = await segmentReqWithSlowClickBreadcrumbPromise;
+
+ const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithSlowClick);
const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected');
const multiClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick');
- expect(slowClickBreadcrumbs).toEqual([
- {
- category: 'ui.slowClickDetected',
- type: 'default',
- data: {
- endReason: 'mutation',
- clickCount: 4,
- node: {
- attributes: {
- id: 'mutationButton',
- },
- id: expect.any(Number),
- tagName: 'button',
- textContent: '******* ********',
+ expect(slowClickBreadcrumbs).toContainEqual({
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ endReason: expect.stringMatching(/^(mutation|timeout)$/),
+ clickCount: 4,
+ node: {
+ attributes: {
+ id: 'mutationButton',
},
- nodeId: expect.any(Number),
- timeAfterClickMs: expect.any(Number),
- url: 'http://sentry-test.io/index.html',
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ********',
},
- message: 'body > button#mutationButton',
- timestamp: expect.any(Number),
+ nodeId: expect.any(Number),
+ timeAfterClickMs: expect.any(Number),
+ url: 'http://sentry-test.io/index.html',
},
- ]);
+ message: 'body > button#mutationButton',
+ timestamp: expect.any(Number),
+ });
expect(multiClickBreadcrumbs.length).toEqual(0);
expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000);
- expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3500);
+ expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3501);
});
sentryTest('immediate mutation does not trigger slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => {
@@ -138,7 +141,15 @@ sentryTest('immediate mutation does not trigger slow click', async ({ forceFlush
const url = await getLocalTestUrl({ testDir: __dirname });
- await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]);
+ const replayRequestPromise = waitForReplayRequest(page, 0);
+ const segmentReqWithClickBreadcrumbPromise = waitForReplayRequest(page, (_event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
+
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
+ });
+
+ await page.goto(url);
+ await replayRequestPromise;
await forceFlushReplay();
let slowClickCount = 0;
@@ -150,36 +161,29 @@ sentryTest('immediate mutation does not trigger slow click', async ({ forceFlush
slowClickCount += slowClicks.length;
});
- const [req1] = await Promise.all([
- waitForReplayRequest(page, (_event, res) => {
- const { breadcrumbs } = getCustomRecordingEvents(res);
-
- return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
- }),
- page.locator('#mutationButtonImmediately').click(),
- ]);
-
- const { breadcrumbs } = getCustomRecordingEvents(req1);
-
- expect(breadcrumbs).toEqual([
- {
- category: 'ui.click',
- data: {
- node: {
- attributes: {
- id: 'mutationButtonImmediately',
- },
- id: expect.any(Number),
- tagName: 'button',
- textContent: '******* ******** ***********',
+ await page.locator('#mutationButtonImmediately').click();
+
+ const segmentReqWithSlowClick = await segmentReqWithClickBreadcrumbPromise;
+
+ const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithSlowClick);
+
+ expect(breadcrumbs).toContainEqual({
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mutationButtonImmediately',
},
- nodeId: expect.any(Number),
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ******** ***********',
},
- message: 'body > button#mutationButtonImmediately',
- timestamp: expect.any(Number),
- type: 'default',
+ nodeId: expect.any(Number),
},
- ]);
+ message: 'body > button#mutationButtonImmediately',
+ timestamp: expect.any(Number),
+ type: 'default',
+ });
// Ensure we wait for timeout, to make sure no slow click is created
// Waiting for 3500 + 1s rounding room
@@ -204,39 +208,41 @@ sentryTest('inline click handler does not trigger slow click', async ({ forceFlu
const url = await getLocalTestUrl({ testDir: __dirname });
- await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]);
+ const replayRequestPromise = waitForReplayRequest(page, 0);
+ const segmentReqWithClickBreadcrumbPromise = waitForReplayRequest(page, (event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
+
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
+ });
+
+ await page.goto(url);
+ await replayRequestPromise;
+
await forceFlushReplay();
- const [req1] = await Promise.all([
- waitForReplayRequest(page, (event, res) => {
- const { breadcrumbs } = getCustomRecordingEvents(res);
-
- return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
- }),
- page.locator('#mutationButtonInline').click(),
- ]);
-
- const { breadcrumbs } = getCustomRecordingEvents(req1);
-
- expect(breadcrumbs).toEqual([
- {
- category: 'ui.click',
- data: {
- node: {
- attributes: {
- id: 'mutationButtonInline',
- },
- id: expect.any(Number),
- tagName: 'button',
- textContent: '******* ******** ***********',
+ await page.locator('#mutationButtonInline').click();
+
+ const segmentReqWithClick = await segmentReqWithClickBreadcrumbPromise;
+
+ const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithClick);
+
+ expect(breadcrumbs).toContainEqual({
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mutationButtonInline',
},
- nodeId: expect.any(Number),
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ******** ***********',
},
- message: 'body > button#mutationButtonInline',
- timestamp: expect.any(Number),
- type: 'default',
+ nodeId: expect.any(Number),
},
- ]);
+ message: 'body > button#mutationButtonInline',
+ timestamp: expect.any(Number),
+ type: 'default',
+ });
});
sentryTest('mouseDown events are considered', async ({ getLocalTestUrl, page }) => {
@@ -254,36 +260,36 @@ sentryTest('mouseDown events are considered', async ({ getLocalTestUrl, page })
const url = await getLocalTestUrl({ testDir: __dirname });
- await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]);
-
- const [req1] = await Promise.all([
- waitForReplayRequest(page, (event, res) => {
- const { breadcrumbs } = getCustomRecordingEvents(res);
-
- return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
- }),
- page.locator('#mouseDownButton').click(),
- ]);
-
- const { breadcrumbs } = getCustomRecordingEvents(req1);
-
- expect(breadcrumbs).toEqual([
- {
- category: 'ui.click',
- data: {
- node: {
- attributes: {
- id: 'mouseDownButton',
- },
- id: expect.any(Number),
- tagName: 'button',
- textContent: '******* ******** ** ***** ****',
+ const replayRequestPromise = waitForReplayRequest(page, 0);
+ const segmentReqWithClickBreadcrumbPromise = waitForReplayRequest(page, (event, res) => {
+ const { breadcrumbs } = getCustomRecordingEvents(res);
+
+ return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click');
+ });
+
+ await page.goto(url);
+ await replayRequestPromise;
+
+ await page.locator('#mouseDownButton').click();
+ const segmentReqWithClick = await segmentReqWithClickBreadcrumbPromise;
+
+ const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithClick);
+
+ expect(breadcrumbs).toContainEqual({
+ category: 'ui.click',
+ data: {
+ node: {
+ attributes: {
+ id: 'mouseDownButton',
},
- nodeId: expect.any(Number),
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* ******** ** ***** ****',
},
- message: 'body > button#mouseDownButton',
- timestamp: expect.any(Number),
- type: 'default',
+ nodeId: expect.any(Number),
},
- ]);
+ message: 'body > button#mouseDownButton',
+ timestamp: expect.any(Number),
+ type: 'default',
+ });
});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts
index 887284585ae1..9217249faad0 100644
--- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts
@@ -121,3 +121,77 @@ test('Sends an API route transaction from module', async ({ baseURL }) => {
}),
);
});
+
+test('API route transaction includes exception filter span for global filter', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /example-module/expected-exception' &&
+ transactionEvent?.request?.url?.includes('/example-module/expected-exception')
+ );
+ });
+
+ const response = await fetch(`${baseURL}/example-module/expected-exception`);
+ expect(response.status).toBe(400);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'ExampleExceptionFilter',
+ parent_span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+});
+
+test('API route transaction includes exception filter span for local filter', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('nestjs-with-submodules', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /example-module-local-filter/expected-exception' &&
+ transactionEvent?.request?.url?.includes('/example-module-local-filter/expected-exception')
+ );
+ });
+
+ const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`);
+ expect(response.status).toBe(400);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'LocalExampleExceptionFilter',
+ parent_span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte
index e7788b6433cd..0cfae1c54741 100644
--- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte
@@ -38,4 +38,7 @@
Component Tracking
+
+ server routes
+
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.svelte
new file mode 100644
index 000000000000..adc04d52c0ea
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.svelte
@@ -0,0 +1,7 @@
+
+
+
+ Message from API: {data.myMessage}
+
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.ts
new file mode 100644
index 000000000000..3f3f5942366e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.ts
@@ -0,0 +1,7 @@
+export const load = async ({ fetch }) => {
+ const res = await fetch('/wrap-server-route/api');
+ const myMessage = await res.json();
+ return {
+ myMessage: myMessage.myMessage,
+ };
+};
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/api/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/api/+server.ts
new file mode 100644
index 000000000000..6ba210690ad5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/api/+server.ts
@@ -0,0 +1,6 @@
+import { wrapServerRouteWithSentry } from '@sentry/sveltekit';
+import { error } from '@sveltejs/kit';
+
+export const GET = wrapServerRouteWithSentry(async () => {
+ error(500, 'error() error');
+});
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts
index c9dc56b9c96b..fd2e58e9c2a3 100644
--- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts
@@ -58,4 +58,37 @@ test.describe('server-side errors', () => {
expect(errorEvent.transaction).toEqual('GET /server-route-error');
});
+
+ test('captures error() thrown in server route with `wrapServerRouteWithSentry`', async ({ page }) => {
+ const errorEventPromise = waitForError('sveltekit-2', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === "'HttpError' captured as exception with keys: body, status";
+ });
+
+ await page.goto('/wrap-server-route');
+
+ expect(await errorEventPromise).toMatchObject({
+ exception: {
+ values: [
+ {
+ value: "'HttpError' captured as exception with keys: body, status",
+ mechanism: {
+ handled: false,
+ data: {
+ function: 'serverRoute',
+ },
+ },
+ stacktrace: { frames: expect.any(Array) },
+ },
+ ],
+ },
+ extra: {
+ __serialized__: {
+ body: {
+ message: 'error() error',
+ },
+ status: 500,
+ },
+ },
+ });
+ });
});
diff --git a/dev-packages/node-integration-tests/.eslintrc.js b/dev-packages/node-integration-tests/.eslintrc.js
index df04aa267446..51b7dfbb7ed3 100644
--- a/dev-packages/node-integration-tests/.eslintrc.js
+++ b/dev-packages/node-integration-tests/.eslintrc.js
@@ -20,6 +20,15 @@ module.exports = {
},
rules: {
'@typescript-eslint/typedef': 'off',
+ // Explicitly allow ts-ignore with description for Node integration tests
+ // Reason: We run these tests on TS3.8 which doesn't support `@ts-expect-error`
+ '@typescript-eslint/ban-ts-comment': [
+ 'error',
+ {
+ 'ts-ignore': 'allow-with-description',
+ 'ts-expect-error': true,
+ },
+ ],
},
},
],
diff --git a/dev-packages/node-integration-tests/suites/express/multiple-init/server.ts b/dev-packages/node-integration-tests/suites/express/multiple-init/server.ts
index aade87fe3bcf..4d1625035ebf 100644
--- a/dev-packages/node-integration-tests/suites/express/multiple-init/server.ts
+++ b/dev-packages/node-integration-tests/suites/express/multiple-init/server.ts
@@ -52,7 +52,17 @@ app.get('/test/error/:id', (req, res) => {
Sentry.captureException(new Error(`This is an exception ${id}`));
- res.send({});
+ setTimeout(() => {
+ // We flush to ensure we are sending exceptions in a certain order
+ Sentry.flush(3000).then(
+ () => {
+ res.send({});
+ },
+ () => {
+ res.send({});
+ },
+ );
+ }, 1000);
});
Sentry.setupExpressErrorHandler(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/apollo-server.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/apollo-server.js
new file mode 100644
index 000000000000..8c1817564196
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/apollo-server.js
@@ -0,0 +1,33 @@
+const { ApolloServer, gql } = require('apollo-server');
+const Sentry = require('@sentry/node');
+
+module.exports = () => {
+ return Sentry.startSpan({ name: 'Test Server Start' }, () => {
+ return new ApolloServer({
+ typeDefs: gql`type Query {
+ hello: String
+ world: String
+ }
+ type Mutation {
+ login(email: String): String
+ }`,
+ resolvers: {
+ Query: {
+ hello: () => {
+ return 'Hello!';
+ },
+ world: () => {
+ return 'World!';
+ },
+ },
+ Mutation: {
+ login: async (_, { email }) => {
+ return `${email}--token`;
+ },
+ },
+ },
+ introspection: false,
+ debug: false,
+ });
+ });
+};
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js
index 9cecf2302315..6defe777d464 100644
--- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-mutation.js
@@ -12,7 +12,8 @@ Sentry.init({
setInterval(() => {}, 1000);
async function run() {
- const { ApolloServer, gql } = require('apollo-server');
+ const { gql } = require('apollo-server');
+ const server = require('./apollo-server')();
await Sentry.startSpan(
{
@@ -20,29 +21,6 @@ async function run() {
op: 'transaction',
},
async span => {
- const server = new ApolloServer({
- typeDefs: gql`
- type Query {
- hello: String
- }
- type Mutation {
- login(email: String): String
- }
- `,
- resolvers: {
- Query: {
- hello: () => {
- return 'Hello world!';
- },
- },
- Mutation: {
- login: async (_, { email }) => {
- return `${email}--token`;
- },
- },
- },
- });
-
// Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
await server.executeOperation({
query: gql`mutation Mutation($email: String){
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js
index f0c140fd4b24..b9a05c4b1c3c 100644
--- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/scenario-query.js
@@ -12,7 +12,7 @@ Sentry.init({
setInterval(() => {}, 1000);
async function run() {
- const { ApolloServer, gql } = require('apollo-server');
+ const server = require('./apollo-server')();
await Sentry.startSpan(
{
@@ -20,21 +20,6 @@ async function run() {
op: 'transaction',
},
async span => {
- const typeDefs = gql`type Query { hello: String }`;
-
- const resolvers = {
- Query: {
- hello: () => {
- return 'Hello world!';
- },
- },
- };
-
- const server = new ApolloServer({
- typeDefs,
- resolvers,
- });
-
// Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
await server.executeOperation({
query: '{hello}',
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts
index 5bf91f7653c1..46e05acf940e 100644
--- a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/test.ts
@@ -1,7 +1,12 @@
import { createRunner } from '../../../utils/runner';
+// Graphql Instrumentation emits some spans by default on server start
+const EXPECTED_START_SERVER_TRANSACTION = {
+ transaction: 'Test Server Start',
+};
+
describe('GraphQL/Apollo Tests', () => {
- test('CJS - should instrument GraphQL queries used from Apollo Server.', done => {
+ test('should instrument GraphQL queries used from Apollo Server.', done => {
const EXPECTED_TRANSACTION = {
transaction: 'Test Transaction',
spans: expect.arrayContaining([
@@ -18,10 +23,13 @@ describe('GraphQL/Apollo Tests', () => {
]),
};
- createRunner(__dirname, 'scenario-query.js').expect({ transaction: EXPECTED_TRANSACTION }).start(done);
+ createRunner(__dirname, 'scenario-query.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
});
- test('CJS - should instrument GraphQL mutations used from Apollo Server.', done => {
+ test('should instrument GraphQL mutations used from Apollo Server.', done => {
const EXPECTED_TRANSACTION = {
transaction: 'Test Transaction',
spans: expect.arrayContaining([
@@ -39,6 +47,9 @@ describe('GraphQL/Apollo Tests', () => {
]),
};
- createRunner(__dirname, 'scenario-mutation.js').expect({ transaction: EXPECTED_TRANSACTION }).start(done);
+ createRunner(__dirname, 'scenario-mutation.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
});
});
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-invalid-root-span.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-invalid-root-span.js
new file mode 100644
index 000000000000..840a5551b98a
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-invalid-root-span.js
@@ -0,0 +1,34 @@
+const Sentry = require('@sentry/node');
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })],
+ transport: loggingTransport,
+});
+
+const tracer = client.tracer;
+
+// Stop the process from exiting before the transaction is sent
+setInterval(() => {}, 1000);
+
+async function run() {
+ const server = require('../apollo-server')();
+
+ await tracer.startActiveSpan('test span name', async span => {
+ // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
+ await server.executeOperation({
+ query: 'query GetHello {hello}',
+ });
+
+ setTimeout(() => {
+ span.end();
+ server.stop();
+ }, 500);
+ });
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations-many.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations-many.js
new file mode 100644
index 000000000000..992ff5337b46
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations-many.js
@@ -0,0 +1,43 @@
+const Sentry = require('@sentry/node');
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })],
+ transport: loggingTransport,
+});
+
+const tracer = client.tracer;
+
+// Stop the process from exiting before the transaction is sent
+setInterval(() => {}, 1000);
+
+async function run() {
+ const server = require('../apollo-server')();
+
+ await tracer.startActiveSpan(
+ 'test span name',
+ {
+ kind: 1,
+ attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' },
+ },
+ async span => {
+ for (let i = 1; i < 10; i++) {
+ // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
+ await server.executeOperation({
+ query: `query GetHello${i} {hello}`,
+ });
+ }
+
+ setTimeout(() => {
+ span.end();
+ server.stop();
+ }, 500);
+ },
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations.js
new file mode 100644
index 000000000000..d9eeca63ae10
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-multiple-operations.js
@@ -0,0 +1,45 @@
+const Sentry = require('@sentry/node');
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })],
+ transport: loggingTransport,
+});
+
+const tracer = client.tracer;
+
+// Stop the process from exiting before the transaction is sent
+setInterval(() => {}, 1000);
+
+async function run() {
+ const server = require('../apollo-server')();
+
+ await tracer.startActiveSpan(
+ 'test span name',
+ {
+ kind: 1,
+ attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' },
+ },
+ async span => {
+ // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
+ await server.executeOperation({
+ query: 'query GetWorld {world}',
+ });
+
+ await server.executeOperation({
+ query: 'query GetHello {hello}',
+ });
+
+ setTimeout(() => {
+ span.end();
+ server.stop();
+ }, 500);
+ },
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-mutation.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-mutation.js
new file mode 100644
index 000000000000..8ee9154c0e51
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-mutation.js
@@ -0,0 +1,45 @@
+const Sentry = require('@sentry/node');
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })],
+ transport: loggingTransport,
+});
+
+const tracer = client.tracer;
+
+// Stop the process from exiting before the transaction is sent
+setInterval(() => {}, 1000);
+
+async function run() {
+ const { gql } = require('apollo-server');
+ const server = require('../apollo-server')();
+
+ await tracer.startActiveSpan(
+ 'test span name',
+ {
+ kind: 1,
+ attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' },
+ },
+ async span => {
+ // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
+ await server.executeOperation({
+ query: gql`mutation TestMutation($email: String){
+ login(email: $email)
+ }`,
+ variables: { email: 'test@email.com' },
+ });
+
+ setTimeout(() => {
+ span.end();
+ server.stop();
+ }, 500);
+ },
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-no-operation-name.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-no-operation-name.js
new file mode 100644
index 000000000000..14879bc0e79d
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-no-operation-name.js
@@ -0,0 +1,41 @@
+const Sentry = require('@sentry/node');
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })],
+ transport: loggingTransport,
+});
+
+const tracer = client.tracer;
+
+// Stop the process from exiting before the transaction is sent
+setInterval(() => {}, 1000);
+
+async function run() {
+ const server = require('../apollo-server')();
+
+ await tracer.startActiveSpan(
+ 'test span name',
+ {
+ kind: 1,
+ attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' },
+ },
+ async span => {
+ // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
+ await server.executeOperation({
+ query: 'query {hello}',
+ });
+
+ setTimeout(() => {
+ span.end();
+ server.stop();
+ }, 500);
+ },
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-query.js b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-query.js
new file mode 100644
index 000000000000..4dc3357ab17f
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/scenario-query.js
@@ -0,0 +1,41 @@
+const Sentry = require('@sentry/node');
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+
+const client = Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ integrations: [Sentry.graphqlIntegration({ useOperationNameForRootSpan: true })],
+ transport: loggingTransport,
+});
+
+const tracer = client.tracer;
+
+// Stop the process from exiting before the transaction is sent
+setInterval(() => {}, 1000);
+
+async function run() {
+ const server = require('../apollo-server')();
+
+ await tracer.startActiveSpan(
+ 'test span name',
+ {
+ kind: 1,
+ attributes: { 'http.method': 'GET', 'http.route': '/test-graphql' },
+ },
+ async span => {
+ // Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
+ await server.executeOperation({
+ query: 'query GetHello {hello}',
+ });
+
+ setTimeout(() => {
+ span.end();
+ server.stop();
+ }, 500);
+ },
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-floating-promises
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts
new file mode 100644
index 000000000000..234cc4009b38
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/apollo-graphql/useOperationNameForRootSpan/test.ts
@@ -0,0 +1,152 @@
+import { createRunner } from '../../../../utils/runner';
+
+// Graphql Instrumentation emits some spans by default on server start
+const EXPECTED_START_SERVER_TRANSACTION = {
+ transaction: 'Test Server Start',
+};
+
+describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => {
+ test('useOperationNameForRootSpan works with single query operation', done => {
+ const EXPECTED_TRANSACTION = {
+ transaction: 'GET /test-graphql (query GetHello)',
+ spans: expect.arrayContaining([
+ expect.objectContaining({
+ data: {
+ 'graphql.operation.name': 'GetHello',
+ 'graphql.operation.type': 'query',
+ 'graphql.source': 'query GetHello {hello}',
+ 'sentry.origin': 'auto.graphql.otel.graphql',
+ },
+ description: 'query GetHello',
+ status: 'ok',
+ origin: 'auto.graphql.otel.graphql',
+ }),
+ ]),
+ };
+
+ createRunner(__dirname, 'scenario-query.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
+ });
+
+ test('useOperationNameForRootSpan works with single mutation operation', done => {
+ const EXPECTED_TRANSACTION = {
+ transaction: 'GET /test-graphql (mutation TestMutation)',
+ spans: expect.arrayContaining([
+ expect.objectContaining({
+ data: {
+ 'graphql.operation.name': 'TestMutation',
+ 'graphql.operation.type': 'mutation',
+ 'graphql.source': `mutation TestMutation($email: String) {
+ login(email: $email)
+}`,
+ 'sentry.origin': 'auto.graphql.otel.graphql',
+ },
+ description: 'mutation TestMutation',
+ status: 'ok',
+ origin: 'auto.graphql.otel.graphql',
+ }),
+ ]),
+ };
+
+ createRunner(__dirname, 'scenario-mutation.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
+ });
+
+ test('useOperationNameForRootSpan ignores an invalid root span', done => {
+ const EXPECTED_TRANSACTION = {
+ transaction: 'test span name',
+ spans: expect.arrayContaining([
+ expect.objectContaining({
+ data: {
+ 'graphql.operation.name': 'GetHello',
+ 'graphql.operation.type': 'query',
+ 'graphql.source': 'query GetHello {hello}',
+ 'sentry.origin': 'auto.graphql.otel.graphql',
+ },
+ description: 'query GetHello',
+ status: 'ok',
+ origin: 'auto.graphql.otel.graphql',
+ }),
+ ]),
+ };
+
+ createRunner(__dirname, 'scenario-invalid-root-span.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
+ });
+
+ test('useOperationNameForRootSpan works with single query operation without name', done => {
+ const EXPECTED_TRANSACTION = {
+ transaction: 'GET /test-graphql (query)',
+ spans: expect.arrayContaining([
+ expect.objectContaining({
+ data: {
+ 'graphql.operation.type': 'query',
+ 'graphql.source': 'query {hello}',
+ 'sentry.origin': 'auto.graphql.otel.graphql',
+ },
+ description: 'query',
+ status: 'ok',
+ origin: 'auto.graphql.otel.graphql',
+ }),
+ ]),
+ };
+
+ createRunner(__dirname, 'scenario-no-operation-name.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
+ });
+
+ test('useOperationNameForRootSpan works with multiple query operations', done => {
+ const EXPECTED_TRANSACTION = {
+ transaction: 'GET /test-graphql (query GetHello, query GetWorld)',
+ spans: expect.arrayContaining([
+ expect.objectContaining({
+ data: {
+ 'graphql.operation.name': 'GetHello',
+ 'graphql.operation.type': 'query',
+ 'graphql.source': 'query GetHello {hello}',
+ 'sentry.origin': 'auto.graphql.otel.graphql',
+ },
+ description: 'query GetHello',
+ status: 'ok',
+ origin: 'auto.graphql.otel.graphql',
+ }),
+ expect.objectContaining({
+ data: {
+ 'graphql.operation.name': 'GetWorld',
+ 'graphql.operation.type': 'query',
+ 'graphql.source': 'query GetWorld {world}',
+ 'sentry.origin': 'auto.graphql.otel.graphql',
+ },
+ description: 'query GetWorld',
+ status: 'ok',
+ origin: 'auto.graphql.otel.graphql',
+ }),
+ ]),
+ };
+
+ createRunner(__dirname, 'scenario-multiple-operations.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
+ });
+
+ test('useOperationNameForRootSpan works with more than 5 query operations', done => {
+ const EXPECTED_TRANSACTION = {
+ transaction:
+ 'GET /test-graphql (query GetHello1, query GetHello2, query GetHello3, query GetHello4, query GetHello5, +4)',
+ };
+
+ createRunner(__dirname, 'scenario-multiple-operations-many.js')
+ .expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done);
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags/server.js b/dev-packages/node-integration-tests/suites/tracing/meta-tags/server.js
new file mode 100644
index 000000000000..35e204a05d6b
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags/server.js
@@ -0,0 +1,33 @@
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+const Sentry = require('@sentry/node');
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+});
+
+// express must be required after Sentry is initialized
+const express = require('express');
+const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests');
+
+const app = express();
+
+app.get('/test', (_req, res) => {
+ res.send({
+ response: `
+
+
+ ${Sentry.getTraceMetaTags()}
+
+
+ Hi :)
+
+
+ `,
+ });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts
new file mode 100644
index 000000000000..c42269dd8504
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags/test.ts
@@ -0,0 +1,25 @@
+import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
+
+describe('getTraceMetaTags', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ test('injects sentry tracing tags', async () => {
+ const traceId = 'cd7ee7a6fe3ebe7ab9c3271559bc203c';
+ const parentSpanId = '100ff0980e7a4ead';
+
+ const runner = createRunner(__dirname, 'server.js').start();
+
+ const response = await runner.makeRequest('get', '/test', {
+ 'sentry-trace': `${traceId}-${parentSpanId}-1`,
+ baggage: 'sentry-environment=production',
+ });
+
+ // @ts-ignore - response is defined, types just don't reflect it
+ const html = response?.response as unknown as string;
+
+ expect(html).toMatch(//);
+ expect(html).toContain('');
+ });
+});
diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts
index e38a552feb39..8a4617c652cf 100644
--- a/packages/astro/src/client/sdk.ts
+++ b/packages/astro/src/client/sdk.ts
@@ -4,7 +4,7 @@ import {
getDefaultIntegrations as getBrowserDefaultIntegrations,
init as initBrowserSdk,
} from '@sentry/browser';
-import { applySdkMetadata, hasTracingEnabled } from '@sentry/core';
+import { applySdkMetadata } from '@sentry/core';
import type { Client, Integration } from '@sentry/types';
// Tree-shakable guard to remove all code related to tracing
@@ -26,14 +26,12 @@ export function init(options: BrowserOptions): Client | undefined {
return initBrowserSdk(opts);
}
-function getDefaultIntegrations(options: BrowserOptions): Integration[] | undefined {
+function getDefaultIntegrations(options: BrowserOptions): Integration[] {
// This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false",
- // in which case everything inside will get treeshaken away
+ // in which case everything inside will get tree-shaken away
if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) {
- if (hasTracingEnabled(options)) {
- return [...getBrowserDefaultIntegrations(options), browserTracingIntegration()];
- }
+ return [...getBrowserDefaultIntegrations(options), browserTracingIntegration()];
+ } else {
+ return getBrowserDefaultIntegrations(options);
}
-
- return undefined;
}
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index 1084643584d6..7f52ad0dc0bb 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -56,6 +56,7 @@ export {
getSpanDescendants,
getSpanStatusFromHttpCode,
getTraceData,
+ getTraceMetaTags,
graphqlIntegration,
hapiIntegration,
httpIntegration,
diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts
index 9baee4d9dc39..0a367a1bdf5d 100644
--- a/packages/astro/src/integration/index.ts
+++ b/packages/astro/src/integration/index.ts
@@ -3,6 +3,7 @@ import * as path from 'path';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import type { AstroConfig, AstroIntegration } from 'astro';
+import { dropUndefinedKeys } from '@sentry/utils';
import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from './snippets';
import type { SentryOptions } from './types';
@@ -40,21 +41,29 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => {
sourcemap: true,
},
plugins: [
- sentryVitePlugin({
- org: uploadOptions.org ?? env.SENTRY_ORG,
- project: uploadOptions.project ?? env.SENTRY_PROJECT,
- authToken: uploadOptions.authToken ?? env.SENTRY_AUTH_TOKEN,
- telemetry: uploadOptions.telemetry ?? true,
- sourcemaps: {
- assets: uploadOptions.assets ?? [getSourcemapsAssetsGlob(config)],
- },
- _metaOptions: {
- telemetry: {
- metaFramework: 'astro',
+ sentryVitePlugin(
+ dropUndefinedKeys({
+ org: uploadOptions.org ?? env.SENTRY_ORG,
+ project: uploadOptions.project ?? env.SENTRY_PROJECT,
+ authToken: uploadOptions.authToken ?? env.SENTRY_AUTH_TOKEN,
+ telemetry: uploadOptions.telemetry ?? true,
+ sourcemaps: {
+ assets: uploadOptions.assets ?? [getSourcemapsAssetsGlob(config)],
},
- },
- debug: options.debug ?? false,
- }),
+ bundleSizeOptimizations: {
+ ...options.bundleSizeOptimizations,
+ // TODO: with a future version of the vite plugin (probably 2.22.0) this re-mapping is not needed anymore
+ // ref: https://github.com/getsentry/sentry-javascript-bundler-plugins/pull/582
+ excludePerformanceMonitoring: options.bundleSizeOptimizations?.excludeTracing,
+ },
+ _metaOptions: {
+ telemetry: {
+ metaFramework: 'astro',
+ },
+ },
+ debug: options.debug ?? false,
+ }),
+ ),
],
},
});
diff --git a/packages/astro/src/integration/snippets.ts b/packages/astro/src/integration/snippets.ts
index c46267080aa8..4b784170d330 100644
--- a/packages/astro/src/integration/snippets.ts
+++ b/packages/astro/src/integration/snippets.ts
@@ -47,7 +47,7 @@ const buildCommonInitOptions = (options: SentryOptions): string => `dsn: ${
}`;
/**
- * We don't include the `BrowserTracing` integration if the tracesSampleRate is set to 0.
+ * We don't include the `BrowserTracing` integration if `bundleSizeOptimizations.excludeTracing` is falsy.
* Likewise, we don't include the `Replay` integration if the replaysSessionSampleRate
* and replaysOnErrorSampleRate are set to 0.
*
@@ -56,7 +56,7 @@ const buildCommonInitOptions = (options: SentryOptions): string => `dsn: ${
const buildClientIntegrations = (options: SentryOptions): string => {
const integrations: string[] = [];
- if (options.tracesSampleRate == null || options.tracesSampleRate) {
+ if (!options.bundleSizeOptimizations?.excludeTracing) {
integrations.push('Sentry.browserTracingIntegration()');
}
diff --git a/packages/astro/src/integration/types.ts b/packages/astro/src/integration/types.ts
index f51a020bb290..026fcd01d8c4 100644
--- a/packages/astro/src/integration/types.ts
+++ b/packages/astro/src/integration/types.ts
@@ -25,62 +25,95 @@ type SdkInitPaths = {
type SourceMapsOptions = {
/**
- * Options for the Sentry Vite plugin to customize the source maps upload process.
+ * If this flag is `true`, and an auth token is detected, the Sentry integration will
+ * automatically generate and upload source maps to Sentry during a production build.
*
- * These options are always read from the `sentryAstro` integration.
- * Do not define them in the `sentry.client.config.(js|ts)` or `sentry.server.config.(js|ts)` files.
+ * @default true
*/
- sourceMapsUploadOptions?: {
- /**
- * If this flag is `true`, and an auth token is detected, the Sentry integration will
- * automatically generate and upload source maps to Sentry during a production build.
- *
- * @default true
- */
- enabled?: boolean;
+ enabled?: boolean;
- /**
- * The auth token to use when uploading source maps to Sentry.
- *
- * Instead of specifying this option, you can also set the `SENTRY_AUTH_TOKEN` environment variable.
- *
- * To create an auth token, follow this guide:
- * @see https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens
- */
- authToken?: string;
+ /**
+ * The auth token to use when uploading source maps to Sentry.
+ *
+ * Instead of specifying this option, you can also set the `SENTRY_AUTH_TOKEN` environment variable.
+ *
+ * To create an auth token, follow this guide:
+ * @see https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens
+ */
+ authToken?: string;
- /**
- * The organization slug of your Sentry organization.
- * Instead of specifying this option, you can also set the `SENTRY_ORG` environment variable.
- */
- org?: string;
+ /**
+ * The organization slug of your Sentry organization.
+ * Instead of specifying this option, you can also set the `SENTRY_ORG` environment variable.
+ */
+ org?: string;
- /**
- * The project slug of your Sentry project.
- * Instead of specifying this option, you can also set the `SENTRY_PROJECT` environment variable.
- */
- project?: string;
+ /**
+ * The project slug of your Sentry project.
+ * Instead of specifying this option, you can also set the `SENTRY_PROJECT` environment variable.
+ */
+ project?: string;
- /**
- * If this flag is `true`, the Sentry plugin will collect some telemetry data and send it to Sentry.
- * It will not collect any sensitive or user-specific data.
- *
- * @default true
- */
- telemetry?: boolean;
+ /**
+ * If this flag is `true`, the Sentry plugin will collect some telemetry data and send it to Sentry.
+ * It will not collect any sensitive or user-specific data.
+ *
+ * @default true
+ */
+ telemetry?: boolean;
- /**
- * A glob or an array of globs that specify the build artifacts and source maps that will be uploaded to Sentry.
- *
- * If this option is not specified, sensible defaults based on your `outDir`, `rootDir` and `adapter`
- * config will be used. Use this option to override these defaults, for instance if you have a
- * customized build setup that diverges from Astro's defaults.
- *
- * The globbing patterns must follow the implementation of the `glob` package.
- * @see https://www.npmjs.com/package/glob#glob-primer
- */
- assets?: string | Array;
- };
+ /**
+ * A glob or an array of globs that specify the build artifacts and source maps that will be uploaded to Sentry.
+ *
+ * If this option is not specified, sensible defaults based on your `outDir`, `rootDir` and `adapter`
+ * config will be used. Use this option to override these defaults, for instance if you have a
+ * customized build setup that diverges from Astro's defaults.
+ *
+ * The globbing patterns must follow the implementation of the `glob` package.
+ * @see https://www.npmjs.com/package/glob#glob-primer
+ */
+ assets?: string | Array;
+};
+
+type BundleSizeOptimizationOptions = {
+ /**
+ * If set to `true`, the plugin will attempt to tree-shake (remove) any debugging code within the Sentry SDK.
+ * 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 plugin will try to tree-shake performance monitoring statements out.
+ * Note that the success of this depends on tree shaking generally being enabled in your build.
+ * Attention: 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 plugin will attempt to tree-shake (remove) code related to the Sentry 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 plugin will attempt to tree-shake (remove) code related to the Sentry 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 plugin will attempt to tree-shake (remove) code related to the Sentry 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 do 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;
};
type InstrumentationOptions = {
@@ -138,6 +171,20 @@ type SdkEnabledOptions = {
export type SentryOptions = SdkInitPaths &
Pick &
Pick &
- SourceMapsOptions &
InstrumentationOptions &
- SdkEnabledOptions;
+ SdkEnabledOptions & {
+ /**
+ * Options for the Sentry Vite plugin to customize the source maps upload process.
+ *
+ * These options are always read from the `sentryAstro` integration.
+ * Do not define them in the `sentry.client.config.(js|ts)` or `sentry.server.config.(js|ts)` files.
+ */
+ sourceMapsUploadOptions?: SourceMapsOptions;
+ /**
+ * Options for the Sentry Vite plugin to customize bundle size optimizations.
+ *
+ * These options are always read from the `sentryAstro` integration.
+ * Do not define them in the `sentry.client.config.(js|ts)` or `sentry.server.config.(js|ts)` files.
+ */
+ bundleSizeOptimizations?: BundleSizeOptimizationOptions;
+ };
diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts
index 6b668f462489..4b2f15eb3be4 100644
--- a/packages/astro/src/server/middleware.ts
+++ b/packages/astro/src/server/middleware.ts
@@ -6,6 +6,7 @@ import {
getActiveSpan,
getClient,
getCurrentScope,
+ getTraceMetaTags,
setHttpStatus,
startSpan,
withIsolationScope,
@@ -14,8 +15,6 @@ import type { Client, Scope, Span, SpanAttributes } from '@sentry/types';
import { addNonEnumerableProperty, objectify, stripUrlQueryAndFragment } from '@sentry/utils';
import type { APIContext, MiddlewareResponseHandler } from 'astro';
-import { getTraceData } from '@sentry/node';
-
type MiddlewareOptions = {
/**
* If true, the client IP will be attached to the event by calling `setUser`.
@@ -189,16 +188,13 @@ function addMetaTagToHead(htmlChunk: string, scope: Scope, client: Client, span?
if (typeof htmlChunk !== 'string') {
return htmlChunk;
}
- const { 'sentry-trace': sentryTrace, baggage } = getTraceData(span, scope, client);
+ const metaTags = getTraceMetaTags(span, scope, client);
- if (!sentryTrace) {
+ if (!metaTags) {
return htmlChunk;
}
- const sentryTraceMeta = ``;
- const baggageMeta = baggage && ``;
-
- const content = `\n${sentryTraceMeta}`.concat(baggageMeta ? `\n${baggageMeta}` : '', '\n');
+ const content = `${metaTags}`;
return htmlChunk.replace('', content);
}
diff --git a/packages/astro/test/client/sdk.test.ts b/packages/astro/test/client/sdk.test.ts
index 1ef31131cb77..41ff4bbae061 100644
--- a/packages/astro/test/client/sdk.test.ts
+++ b/packages/astro/test/client/sdk.test.ts
@@ -53,6 +53,7 @@ describe('Sentry client SDK', () => {
['tracesSampleRate', { tracesSampleRate: 0 }],
['tracesSampler', { tracesSampler: () => 1.0 }],
['enableTracing', { enableTracing: true }],
+ ['no tracing option set', {}],
])('adds browserTracingIntegration if tracing is enabled via %s', (_, tracingOptions) => {
init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
@@ -66,22 +67,6 @@ describe('Sentry client SDK', () => {
expect(browserTracing).toBeDefined();
});
- it.each([
- ['enableTracing', { enableTracing: false }],
- ['no tracing option set', {}],
- ])("doesn't add browserTracingIntegration if tracing is disabled via %s", (_, tracingOptions) => {
- init({
- dsn: 'https://public@dsn.ingest.sentry.io/1337',
- ...tracingOptions,
- });
-
- const integrationsToInit = browserInit.mock.calls[0]![0]?.defaultIntegrations || [];
- const browserTracing = getClient()?.getIntegrationByName('BrowserTracing');
-
- expect(integrationsToInit).not.toContainEqual(expect.objectContaining({ name: 'BrowserTracing' }));
- expect(browserTracing).toBeUndefined();
- });
-
it("doesn't add browserTracingIntegration if `__SENTRY_TRACING__` is set to false", () => {
(globalThis as any).__SENTRY_TRACING__ = false;
diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts
index 008132264602..eb6bdf555ae3 100644
--- a/packages/astro/test/integration/index.test.ts
+++ b/packages/astro/test/integration/index.test.ts
@@ -57,6 +57,7 @@ describe('sentryAstro integration', () => {
project: 'my-project',
telemetry: false,
debug: false,
+ bundleSizeOptimizations: {},
sourcemaps: {
assets: ['out/**/*'],
},
@@ -82,6 +83,7 @@ describe('sentryAstro integration', () => {
project: 'my-project',
telemetry: false,
debug: false,
+ bundleSizeOptimizations: {},
sourcemaps: {
assets: ['dist/**/*'],
},
@@ -114,6 +116,7 @@ describe('sentryAstro integration', () => {
project: 'my-project',
telemetry: false,
debug: false,
+ bundleSizeOptimizations: {},
sourcemaps: {
assets: ['{.vercel,dist}/**/*'],
},
@@ -151,6 +154,7 @@ describe('sentryAstro integration', () => {
project: 'my-project',
telemetry: true,
debug: false,
+ bundleSizeOptimizations: {},
sourcemaps: {
assets: ['dist/server/**/*, dist/client/**/*'],
},
diff --git a/packages/astro/test/integration/snippets.test.ts b/packages/astro/test/integration/snippets.test.ts
index 04aaa866aee9..66a3b15efd58 100644
--- a/packages/astro/test/integration/snippets.test.ts
+++ b/packages/astro/test/integration/snippets.test.ts
@@ -52,7 +52,25 @@ describe('buildClientSnippet', () => {
`);
});
- it('does not include browserTracingIntegration if tracesSampleRate is 0', () => {
+ it('does not include browserTracingIntegration if bundleSizeOptimizations.excludeTracing is true', () => {
+ const snippet = buildClientSnippet({ bundleSizeOptimizations: { excludeTracing: true } });
+ expect(snippet).toMatchInlineSnapshot(`
+ "import * as Sentry from "@sentry/astro";
+
+ Sentry.init({
+ dsn: import.meta.env.PUBLIC_SENTRY_DSN,
+ debug: false,
+ environment: import.meta.env.PUBLIC_VERCEL_ENV,
+ release: import.meta.env.PUBLIC_VERCEL_GIT_COMMIT_SHA,
+ tracesSampleRate: 1,
+ integrations: [Sentry.replayIntegration()],
+ replaysSessionSampleRate: 0.1,
+ replaysOnErrorSampleRate: 1,
+ });"
+ `);
+ });
+
+ it('still include browserTracingIntegration if tracesSampleRate is 0', () => {
const snippet = buildClientSnippet({ tracesSampleRate: 0 });
expect(snippet).toMatchInlineSnapshot(`
"import * as Sentry from "@sentry/astro";
@@ -63,7 +81,7 @@ describe('buildClientSnippet', () => {
environment: import.meta.env.PUBLIC_VERCEL_ENV,
release: import.meta.env.PUBLIC_VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: 0,
- integrations: [Sentry.replayIntegration()],
+ integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
});"
diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts
index 58405c8d1c12..bf96f6ef9046 100644
--- a/packages/astro/test/server/middleware.test.ts
+++ b/packages/astro/test/server/middleware.test.ts
@@ -34,10 +34,12 @@ describe('sentryMiddleware', () => {
});
vi.spyOn(SentryNode, 'getActiveSpan').mockImplementation(getSpanMock);
vi.spyOn(SentryNode, 'getClient').mockImplementation(() => ({}) as Client);
- vi.spyOn(SentryNode, 'getTraceData').mockImplementation(() => ({
- 'sentry-trace': '123',
- baggage: 'abc',
- }));
+ vi.spyOn(SentryNode, 'getTraceMetaTags').mockImplementation(
+ () => `
+
+
+ `,
+ );
vi.spyOn(SentryCore, 'getDynamicSamplingContextFromSpan').mockImplementation(() => ({
transaction: 'test',
}));
diff --git a/packages/aws-serverless/rollup.npm.config.mjs b/packages/aws-serverless/rollup.npm.config.mjs
index 46e006f70b95..0ac3218144d5 100644
--- a/packages/aws-serverless/rollup.npm.config.mjs
+++ b/packages/aws-serverless/rollup.npm.config.mjs
@@ -9,6 +9,10 @@ export default [
entrypoints: ['src/index.ts', 'src/awslambda-auto.ts'],
// packages with bundles have a different build directory structure
hasBundles: true,
+ packageSpecificConfig: {
+ // Used for our custom eventContextExtractor
+ external: ['@opentelemetry/api'],
+ },
}),
),
...makeOtelLoaders('./build', 'sentry-node'),
diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts
index 95b2d553f2d4..20ef9eeaf09f 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -21,6 +21,7 @@ export {
getGlobalScope,
getIsolationScope,
getTraceData,
+ getTraceMetaTags,
setCurrentClient,
Scope,
SDK_VERSION,
diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts
index c6516603b6b8..2e1479914471 100644
--- a/packages/aws-serverless/src/integration/awslambda.ts
+++ b/packages/aws-serverless/src/integration/awslambda.ts
@@ -1,20 +1,44 @@
import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, defineIntegration } from '@sentry/core';
-import { addOpenTelemetryInstrumentation } from '@sentry/node';
+import { generateInstrumentOnce } from '@sentry/node';
import type { IntegrationFn } from '@sentry/types';
+import { eventContextExtractor } from '../utils';
-const _awsLambdaIntegration = (() => {
+interface AwsLambdaOptions {
+ /**
+ * Disables the AWS context propagation and instead uses
+ * Sentry's context. Defaults to `true`, in order for
+ * Sentry trace propagation to take precedence, but can
+ * be disabled if you want AWS propagation to take take
+ * precedence.
+ */
+ disableAwsContextPropagation?: boolean;
+}
+
+export const instrumentAwsLambda = generateInstrumentOnce(
+ 'AwsLambda',
+ (_options: AwsLambdaOptions = {}) => {
+ const options = {
+ disableAwsContextPropagation: true,
+ ..._options,
+ };
+
+ return new AwsLambdaInstrumentation({
+ ...options,
+ eventContextExtractor,
+ requestHook(span) {
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda');
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda');
+ },
+ });
+ },
+);
+
+const _awsLambdaIntegration = ((options: AwsLambdaOptions = {}) => {
return {
name: 'AwsLambda',
setupOnce() {
- addOpenTelemetryInstrumentation(
- new AwsLambdaInstrumentation({
- requestHook(span) {
- span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda');
- span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda');
- },
- }),
- );
+ instrumentAwsLambda(options);
},
};
}) satisfies IntegrationFn;
diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts
index 8ed86d9f23d4..e052782d50eb 100644
--- a/packages/aws-serverless/src/sdk.ts
+++ b/packages/aws-serverless/src/sdk.ts
@@ -16,7 +16,7 @@ import {
withScope,
} from '@sentry/node';
import type { Integration, Options, Scope, SdkMetadata, Span } from '@sentry/types';
-import { isString, logger } from '@sentry/utils';
+import { logger } from '@sentry/utils';
import type { Context, Handler } from 'aws-lambda';
import { performance } from 'perf_hooks';
@@ -25,7 +25,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } fr
import { DEBUG_BUILD } from './debug-build';
import { awsIntegration } from './integration/aws';
import { awsLambdaIntegration } from './integration/awslambda';
-import { markEventUnhandled } from './utils';
+import { getAwsTraceData, markEventUnhandled } from './utils';
const { isPromise } = types;
@@ -334,15 +334,9 @@ export function wrapHandler(
// Otherwise, we create two root spans (one from otel, one from our wrapper).
// If Otel instrumentation didn't work or was filtered by users, we still want to trace the handler.
if (options.startTrace && !isWrappedByOtel(handler)) {
- const eventWithHeaders = event as { headers?: { [key: string]: string } };
+ const traceData = getAwsTraceData(event as { headers?: Record }, context);
- const sentryTrace =
- eventWithHeaders.headers && isString(eventWithHeaders.headers['sentry-trace'])
- ? eventWithHeaders.headers['sentry-trace']
- : undefined;
- const baggage = eventWithHeaders.headers?.baggage;
-
- return continueTrace({ sentryTrace, baggage }, () => {
+ return continueTrace({ sentryTrace: traceData['sentry-trace'], baggage: traceData.baggage }, () => {
return startSpanManual(
{
name: context.functionName,
diff --git a/packages/aws-serverless/src/utils.ts b/packages/aws-serverless/src/utils.ts
index 259388bb193c..f6461030c1a7 100644
--- a/packages/aws-serverless/src/utils.ts
+++ b/packages/aws-serverless/src/utils.ts
@@ -1,5 +1,29 @@
+import type { TextMapGetter } from '@opentelemetry/api';
+import type { Context as OtelContext } from '@opentelemetry/api';
+import { context as otelContext, propagation } from '@opentelemetry/api';
import type { Scope } from '@sentry/types';
-import { addExceptionMechanism } from '@sentry/utils';
+import { addExceptionMechanism, isString } from '@sentry/utils';
+import type { Handler } from 'aws-lambda';
+import type { APIGatewayProxyEventHeaders } from 'aws-lambda';
+
+type HandlerEvent = Parameters }>>[0];
+type HandlerContext = Parameters[1];
+
+type TraceData = {
+ 'sentry-trace'?: string;
+ baggage?: string;
+};
+
+// vendored from
+// https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/plugins/node/opentelemetry-instrumentation-aws-lambda/src/instrumentation.ts#L65-L72
+const headerGetter: TextMapGetter = {
+ keys(carrier): string[] {
+ return Object.keys(carrier);
+ },
+ get(carrier, key: string) {
+ return carrier[key];
+ },
+};
/**
* Marks an event as unhandled by adding a span processor to the passed scope.
@@ -12,3 +36,51 @@ export function markEventUnhandled(scope: Scope): Scope {
return scope;
}
+
+/**
+ * Extracts sentry trace data from the handler `context` if available and falls
+ * back to the `event`.
+ *
+ * When instrumenting the Lambda function with Sentry, the sentry trace data
+ * is placed on `context.clientContext.Custom`. Users are free to modify context
+ * tho and provide this data via `event` or `context`.
+ */
+export function getAwsTraceData(event: HandlerEvent, context?: HandlerContext): TraceData {
+ const headers = event.headers || {};
+
+ const traceData: TraceData = {
+ 'sentry-trace': headers['sentry-trace'],
+ baggage: headers.baggage,
+ };
+
+ if (context && context.clientContext && context.clientContext.Custom) {
+ const customContext: Record = context.clientContext.Custom;
+ const sentryTrace = isString(customContext['sentry-trace']) ? customContext['sentry-trace'] : undefined;
+
+ if (sentryTrace) {
+ traceData['sentry-trace'] = sentryTrace;
+ traceData.baggage = isString(customContext.baggage) ? customContext.baggage : undefined;
+ }
+ }
+
+ return traceData;
+}
+
+/**
+ * A custom event context extractor for the aws integration. It takes sentry trace data
+ * from the context rather than the event, with the event being a fallback.
+ *
+ * Is only used when the handler was successfully wrapped by otel and the integration option
+ * `disableAwsContextPropagation` is `true`.
+ */
+export function eventContextExtractor(event: HandlerEvent, context?: HandlerContext): OtelContext {
+ // The default context extractor tries to get sampled trace headers from HTTP headers
+ // The otel aws integration packs these onto the context, so we try to extract them from
+ // there instead.
+ const httpHeaders = {
+ ...(event.headers || {}),
+ ...getAwsTraceData(event, context),
+ };
+
+ return propagation.extract(otelContext.active(), httpHeaders, headerGetter);
+}
diff --git a/packages/aws-serverless/test/utils.test.ts b/packages/aws-serverless/test/utils.test.ts
new file mode 100644
index 000000000000..197c6ebdf90f
--- /dev/null
+++ b/packages/aws-serverless/test/utils.test.ts
@@ -0,0 +1,102 @@
+import { eventContextExtractor, getAwsTraceData } from '../src/utils';
+
+const mockExtractContext = jest.fn();
+jest.mock('@opentelemetry/api', () => {
+ const actualApi = jest.requireActual('@opentelemetry/api');
+ return {
+ ...actualApi,
+ propagation: {
+ extract: (...args: unknown[]) => mockExtractContext(args),
+ },
+ };
+});
+
+const mockContext = {
+ clientContext: {
+ Custom: {
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ baggage: 'sentry-environment=production',
+ },
+ },
+};
+const mockEvent = {
+ headers: {
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-2',
+ baggage: 'sentry-environment=staging',
+ },
+};
+
+describe('getTraceData', () => {
+ test('gets sentry trace data from the context', () => {
+ // @ts-expect-error, a partial context object is fine here
+ const traceData = getAwsTraceData({}, mockContext);
+
+ expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-1');
+ expect(traceData.baggage).toEqual('sentry-environment=production');
+ });
+
+ test('gets sentry trace data from the context even if event has data', () => {
+ // @ts-expect-error, a partial context object is fine here
+ const traceData = getAwsTraceData(mockEvent, mockContext);
+
+ expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-1');
+ expect(traceData.baggage).toEqual('sentry-environment=production');
+ });
+
+ test('gets sentry trace data from the event if no context is passed', () => {
+ const traceData = getAwsTraceData(mockEvent);
+
+ expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-2');
+ expect(traceData.baggage).toEqual('sentry-environment=staging');
+ });
+
+ test('gets sentry trace data from the event if the context sentry trace is undefined', () => {
+ const traceData = getAwsTraceData(mockEvent, {
+ // @ts-expect-error, a partial context object is fine here
+ clientContext: { Custom: { 'sentry-trace': undefined, baggage: '' } },
+ });
+
+ expect(traceData['sentry-trace']).toEqual('12345678901234567890123456789012-1234567890123456-2');
+ expect(traceData.baggage).toEqual('sentry-environment=staging');
+ });
+});
+
+describe('eventContextExtractor', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('passes sentry trace data to the propagation extractor', () => {
+ // @ts-expect-error, a partial context object is fine here
+ eventContextExtractor(mockEvent, mockContext);
+
+ // @ts-expect-error, a partial context object is fine here
+ const expectedTraceData = getAwsTraceData(mockEvent, mockContext);
+
+ expect(mockExtractContext).toHaveBeenCalledTimes(1);
+ expect(mockExtractContext).toHaveBeenCalledWith(expect.arrayContaining([expectedTraceData]));
+ });
+
+ test('passes along non-sentry trace headers along', () => {
+ eventContextExtractor(
+ {
+ ...mockEvent,
+ headers: {
+ ...mockEvent.headers,
+ 'X-Custom-Header': 'Foo',
+ },
+ },
+ // @ts-expect-error, a partial context object is fine here
+ mockContext,
+ );
+
+ const expectedHeaders = {
+ 'X-Custom-Header': 'Foo',
+ // @ts-expect-error, a partial context object is fine here
+ ...getAwsTraceData(mockEvent, mockContext),
+ };
+
+ expect(mockExtractContext).toHaveBeenCalledTimes(1);
+ expect(mockExtractContext).toHaveBeenCalledWith(expect.arrayContaining([expectedHeaders]));
+ });
+});
diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts
index 1421814ae9e5..04aa82b5f0e6 100644
--- a/packages/browser/src/sdk.ts
+++ b/packages/browser/src/sdk.ts
@@ -57,6 +57,14 @@ function applyDefaultOptions(optionsArg: BrowserOptions = {}): BrowserOptions {
sendClientReports: true,
};
+ // TODO: Instead of dropping just `defaultIntegrations`, we should simply
+ // call `dropUndefinedKeys` on the entire `optionsArg`.
+ // However, for this to work we need to adjust the `hasTracingEnabled()` logic
+ // first as it differentiates between `undefined` and the key not being in the object.
+ if (optionsArg.defaultIntegrations == null) {
+ delete optionsArg.defaultIntegrations;
+ }
+
return { ...defaultOptions, ...optionsArg };
}
diff --git a/packages/browser/test/sdk.test.ts b/packages/browser/test/sdk.test.ts
index 80e54e3d49d2..618333532a09 100644
--- a/packages/browser/test/sdk.test.ts
+++ b/packages/browser/test/sdk.test.ts
@@ -6,6 +6,7 @@
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as SentryCore from '@sentry/core';
import { Scope, createTransport } from '@sentry/core';
import type { Client, Integration } from '@sentry/types';
import { resolvedSyncPromise } from '@sentry/utils';
@@ -79,6 +80,18 @@ describe('init', () => {
expect(DEFAULT_INTEGRATIONS[1]!.setupOnce as Mock).toHaveBeenCalledTimes(1);
});
+ it('installs default integrations if `defaultIntegrations: undefined`', () => {
+ // @ts-expect-error this is fine for testing
+ const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind').mockImplementationOnce(() => {});
+ const options = getDefaultBrowserOptions({ dsn: PUBLIC_DSN, defaultIntegrations: undefined });
+ init(options);
+
+ expect(initAndBindSpy).toHaveBeenCalledTimes(1);
+
+ const optionsPassed = initAndBindSpy.mock.calls[0]?.[1];
+ expect(optionsPassed?.integrations?.length).toBeGreaterThan(0);
+ });
+
test("doesn't install default integrations if told not to", () => {
const DEFAULT_INTEGRATIONS: Integration[] = [
new MockIntegration('MockIntegration 0.3'),
@@ -150,6 +163,7 @@ describe('init', () => {
Object.defineProperty(WINDOW, 'browser', { value: undefined, writable: true });
Object.defineProperty(WINDOW, 'nw', { value: undefined, writable: true });
Object.defineProperty(WINDOW, 'window', { value: WINDOW, writable: true });
+ vi.clearAllMocks();
});
it('logs a browser extension error if executed inside a Chrome extension', () => {
diff --git a/packages/browser/test/utils/lazyLoadIntegration.test.ts b/packages/browser/test/utils/lazyLoadIntegration.test.ts
index ec88ae49a1a9..82548a59faec 100644
--- a/packages/browser/test/utils/lazyLoadIntegration.test.ts
+++ b/packages/browser/test/utils/lazyLoadIntegration.test.ts
@@ -71,9 +71,11 @@ describe('lazyLoadIntegration', () => {
httpClientIntegration: undefined,
};
- // We do not await here, as this this does not seem to work with JSDOM :(
- // We have browser integration tests to check that this actually works
- void lazyLoadIntegration('httpClientIntegration');
+ try {
+ await lazyLoadIntegration('httpClientIntegration');
+ } catch {
+ // skip
+ }
expect(global.document.querySelectorAll('script')).toHaveLength(1);
expect(global.document.querySelector('script')?.src).toEqual(
diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts
index 287dbc26eeee..4de55fd1c5f7 100644
--- a/packages/bun/src/index.ts
+++ b/packages/bun/src/index.ts
@@ -41,6 +41,7 @@ export {
getGlobalScope,
getIsolationScope,
getTraceData,
+ getTraceMetaTags,
setCurrentClient,
Scope,
SDK_VERSION,
diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts
index 2f77f96f4e33..0c02fb8ca810 100644
--- a/packages/cloudflare/src/index.ts
+++ b/packages/cloudflare/src/index.ts
@@ -56,6 +56,7 @@ export {
getActiveSpan,
getRootSpan,
getTraceData,
+ getTraceMetaTags,
startSpan,
startInactiveSpan,
startSpanManual,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 5c21c8e484ed..73295f7df64c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -83,6 +83,7 @@ export {
export { parseSampleRate } from './utils/parseSampleRate';
export { applySdkMetadata } from './utils/sdkMetadata';
export { getTraceData } from './utils/traceData';
+export { getTraceMetaTags } from './utils/meta';
export { DEFAULT_ENVIRONMENT } from './constants';
export { addBreadcrumb } from './breadcrumbs';
export { functionToStringIntegration } from './integrations/functiontostring';
diff --git a/packages/core/src/utils/meta.ts b/packages/core/src/utils/meta.ts
new file mode 100644
index 000000000000..339dfcee2f28
--- /dev/null
+++ b/packages/core/src/utils/meta.ts
@@ -0,0 +1,29 @@
+import type { Client, Scope, Span } from '@sentry/types';
+import { getTraceData } from './traceData';
+
+/**
+ * Returns a string of meta tags that represent the current trace data.
+ *
+ * You can use this to propagate a trace from your server-side rendered Html to the browser.
+ * This function returns up to two meta tags, `sentry-trace` and `baggage`, depending on the
+ * current trace data state.
+ *
+ * @example
+ * Usage example:
+ *
+ * ```js
+ * function renderHtml() {
+ * return `
+ *
+ * ${getTraceMetaTags()}
+ *
+ * `;
+ * }
+ * ```
+ *
+ */
+export function getTraceMetaTags(span?: Span, scope?: Scope, client?: Client): string {
+ return Object.entries(getTraceData(span, scope, client))
+ .map(([key, value]) => ``)
+ .join('\n');
+}
diff --git a/packages/core/test/lib/utils/meta.test.ts b/packages/core/test/lib/utils/meta.test.ts
new file mode 100644
index 000000000000..3d78247b8951
--- /dev/null
+++ b/packages/core/test/lib/utils/meta.test.ts
@@ -0,0 +1,30 @@
+import { getTraceMetaTags } from '../../../src/utils/meta';
+import * as TraceDataModule from '../../../src/utils/traceData';
+
+describe('getTraceMetaTags', () => {
+ it('renders baggage and sentry-trace values to stringified Html meta tags', () => {
+ jest.spyOn(TraceDataModule, 'getTraceData').mockReturnValueOnce({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ baggage: 'sentry-environment=production',
+ });
+
+ expect(getTraceMetaTags()).toBe(`
+`);
+ });
+
+ it('renders just sentry-trace values to stringified Html meta tags', () => {
+ jest.spyOn(TraceDataModule, 'getTraceData').mockReturnValueOnce({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ });
+
+ expect(getTraceMetaTags()).toBe(
+ '',
+ );
+ });
+
+ it('returns an empty string if neither sentry-trace nor baggage values are available', () => {
+ jest.spyOn(TraceDataModule, 'getTraceData').mockReturnValueOnce({});
+
+ expect(getTraceMetaTags()).toBe('');
+ });
+});
diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts
index 69b26bb1729a..1f983b476b74 100644
--- a/packages/deno/src/index.ts
+++ b/packages/deno/src/index.ts
@@ -56,6 +56,7 @@ export {
getActiveSpan,
getRootSpan,
getTraceData,
+ getTraceMetaTags,
startSpan,
startInactiveSpan,
startSpanManual,
diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts
index 351f843d2c2d..73f24f9cf39e 100644
--- a/packages/google-cloud-serverless/src/index.ts
+++ b/packages/google-cloud-serverless/src/index.ts
@@ -21,6 +21,7 @@ export {
getGlobalScope,
getIsolationScope,
getTraceData,
+ getTraceMetaTags,
setCurrentClient,
Scope,
SDK_VERSION,
diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts
index b068ed052a91..5e76f5cbe912 100644
--- a/packages/nestjs/src/setup.ts
+++ b/packages/nestjs/src/setup.ts
@@ -64,6 +64,8 @@ export { SentryTracingInterceptor };
* Global filter to handle exceptions and report them to Sentry.
*/
class SentryGlobalFilter extends BaseExceptionFilter {
+ public static readonly __SENTRY_INTERNAL__ = true;
+
/**
* Catches exceptions and reports them to Sentry unless they are expected errors.
*/
@@ -84,6 +86,8 @@ export { SentryGlobalFilter };
* Service to set up Sentry performance tracing for Nest.js applications.
*/
class SentryService implements OnModuleInit {
+ public static readonly __SENTRY_INTERNAL__ = true;
+
/**
* Initializes the Sentry service and registers span attributes.
*/
diff --git a/packages/nestjs/test/sdk.test.ts b/packages/nestjs/test/sdk.test.ts
index c6bf7166444d..77d19fa2c797 100644
--- a/packages/nestjs/test/sdk.test.ts
+++ b/packages/nestjs/test/sdk.test.ts
@@ -1,7 +1,8 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
import * as SentryNode from '@sentry/node';
import { SDK_VERSION } from '@sentry/utils';
-import { vi } from 'vitest';
import { init as nestInit } from '../src/sdk';
const nodeInit = vi.spyOn(SentryNode, 'init');
diff --git a/packages/nestjs/tsconfig.test.json b/packages/nestjs/tsconfig.test.json
index fc9e549d35ce..00cada2d8bcf 100644
--- a/packages/nestjs/tsconfig.test.json
+++ b/packages/nestjs/tsconfig.test.json
@@ -4,9 +4,6 @@
"include": ["test/**/*", "vite.config.ts"],
"compilerOptions": {
- // should include all types from `./tsconfig.json` plus types for all test frameworks used
- "types": ["vitest/globals"]
-
// other package-specific, test-specific options
}
}
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index badd1f1a27bf..1fdc32d3d77a 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -96,6 +96,7 @@ export {
getCurrentScope,
getIsolationScope,
getTraceData,
+ getTraceMetaTags,
withScope,
withIsolationScope,
captureException,
diff --git a/packages/node/src/integrations/tracing/graphql.ts b/packages/node/src/integrations/tracing/graphql.ts
index 097ee3ba43f8..914653ac745c 100644
--- a/packages/node/src/integrations/tracing/graphql.ts
+++ b/packages/node/src/integrations/tracing/graphql.ts
@@ -1,12 +1,17 @@
import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql';
-import { defineIntegration } from '@sentry/core';
+import { defineIntegration, getRootSpan, spanToJSON } from '@sentry/core';
+import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemetry';
import type { IntegrationFn } from '@sentry/types';
import { generateInstrumentOnce } from '../../otel/instrument';
import { addOriginToSpan } from '../../utils/addOriginToSpan';
interface GraphqlOptions {
- /** Do not create spans for resolvers. */
+ /**
+ * Do not create spans for resolvers.
+ *
+ * Defaults to true.
+ */
ignoreResolveSpans?: boolean;
/**
@@ -16,8 +21,18 @@ interface GraphqlOptions {
* use the default resolver which just looks for a property with that name on the object.
* If the property is not a function, it's not very interesting to trace.
* This option can reduce noise and number of spans created.
+ *
+ * Defaults to true.
+ */
+ ignoreTrivialResolveSpans?: boolean;
+
+ /**
+ * If this is enabled, a http.server root span containing this span will automatically be renamed to include the operation name.
+ * Set this to `false` if you do not want this behavior, and want to keep the default http.server span name.
+ *
+ * Defaults to true.
*/
- ignoreTrivalResolveSpans?: boolean;
+ useOperationNameForRootSpan?: boolean;
}
const INTEGRATION_NAME = 'Graphql';
@@ -28,6 +43,7 @@ export const instrumentGraphql = generateInstrumentOnce(
const options = {
ignoreResolveSpans: true,
ignoreTrivialResolveSpans: true,
+ useOperationNameForRootSpan: true,
..._options,
};
@@ -35,6 +51,35 @@ export const instrumentGraphql = generateInstrumentOnce(
...options,
responseHook(span) {
addOriginToSpan(span, 'auto.graphql.otel.graphql');
+
+ const attributes = spanToJSON(span).data || {};
+
+ // If operation.name is not set, we fall back to use operation.type only
+ const operationType = attributes['graphql.operation.type'];
+ const operationName = attributes['graphql.operation.name'];
+
+ if (options.useOperationNameForRootSpan && operationType) {
+ const rootSpan = getRootSpan(span);
+
+ // We guard to only do this on http.server spans
+
+ const rootSpanAttributes = spanToJSON(rootSpan).data || {};
+
+ const existingOperations = rootSpanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION] || [];
+
+ const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`;
+
+ // We keep track of each operation on the root span
+ // This can either be a string, or an array of strings (if there are multiple operations)
+ if (Array.isArray(existingOperations)) {
+ existingOperations.push(newOperation);
+ rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, existingOperations);
+ } else if (existingOperations) {
+ rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, [existingOperations, newOperation]);
+ } else {
+ rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, newOperation);
+ }
+ }
},
});
},
diff --git a/packages/node/src/integrations/tracing/nest/helpers.ts b/packages/node/src/integrations/tracing/nest/helpers.ts
index 32eb3a0d5a39..babf80022c1f 100644
--- a/packages/node/src/integrations/tracing/nest/helpers.ts
+++ b/packages/node/src/integrations/tracing/nest/helpers.ts
@@ -1,6 +1,6 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import { addNonEnumerableProperty } from '@sentry/utils';
-import type { InjectableTarget } from './types';
+import type { CatchTarget, InjectableTarget } from './types';
const sentryPatched = 'sentryPatched';
@@ -10,7 +10,7 @@ const sentryPatched = 'sentryPatched';
* We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes.
* This check might not be necessary, but better to play it safe.
*/
-export function isPatched(target: InjectableTarget): boolean {
+export function isPatched(target: InjectableTarget | CatchTarget): boolean {
if (target.sentryPatched) {
return true;
}
@@ -23,7 +23,7 @@ export function isPatched(target: InjectableTarget): boolean {
* Returns span options for nest middleware spans.
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-export function getMiddlewareSpanOptions(target: InjectableTarget) {
+export function getMiddlewareSpanOptions(target: InjectableTarget | CatchTarget) {
return {
name: target.name,
attributes: {
diff --git a/packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts b/packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts
index 52c3a4ad6b40..28d5a74ef63d 100644
--- a/packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts
+++ b/packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts
@@ -9,7 +9,7 @@ import { getActiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sent
import type { Span } from '@sentry/types';
import { SDK_VERSION } from '@sentry/utils';
import { getMiddlewareSpanOptions, isPatched } from './helpers';
-import type { InjectableTarget } from './types';
+import type { CatchTarget, InjectableTarget } from './types';
const supportedVersions = ['>=8.0.0 <11'];
@@ -34,7 +34,10 @@ export class SentryNestInstrumentation extends InstrumentationBase {
public init(): InstrumentationNodeModuleDefinition {
const moduleDef = new InstrumentationNodeModuleDefinition(SentryNestInstrumentation.COMPONENT, supportedVersions);
- moduleDef.files.push(this._getInjectableFileInstrumentation(supportedVersions));
+ moduleDef.files.push(
+ this._getInjectableFileInstrumentation(supportedVersions),
+ this._getCatchFileInstrumentation(supportedVersions),
+ );
return moduleDef;
}
@@ -58,10 +61,28 @@ export class SentryNestInstrumentation extends InstrumentationBase {
);
}
+ /**
+ * Wraps the @Catch decorator.
+ */
+ private _getCatchFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile {
+ return new InstrumentationNodeModuleFile(
+ '@nestjs/common/decorators/core/catch.decorator.js',
+ versions,
+ (moduleExports: { Catch: CatchTarget }) => {
+ if (isWrapped(moduleExports.Catch)) {
+ this._unwrap(moduleExports, 'Catch');
+ }
+ this._wrap(moduleExports, 'Catch', this._createWrapCatch());
+ return moduleExports;
+ },
+ (moduleExports: { Catch: CatchTarget }) => {
+ this._unwrap(moduleExports, 'Catch');
+ },
+ );
+ }
+
/**
* Creates a wrapper function for the @Injectable decorator.
- *
- * Wraps the use method to instrument nest class middleware.
*/
private _createWrapInjectable() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -177,4 +198,33 @@ export class SentryNestInstrumentation extends InstrumentationBase {
};
};
}
+
+ /**
+ * Creates a wrapper function for the @Catch decorator. Used to instrument exception filters.
+ */
+ private _createWrapCatch() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function wrapCatch(original: any) {
+ return function wrappedCatch(...exceptions: unknown[]) {
+ return function (target: CatchTarget) {
+ if (typeof target.prototype.catch === 'function' && !target.__SENTRY_INTERNAL__) {
+ // patch only once
+ if (isPatched(target)) {
+ return original(...exceptions)(target);
+ }
+
+ target.prototype.catch = new Proxy(target.prototype.catch, {
+ apply: (originalCatch, thisArgCatch, argsCatch) => {
+ return startSpan(getMiddlewareSpanOptions(target), () => {
+ return originalCatch.apply(thisArgCatch, argsCatch);
+ });
+ },
+ });
+ }
+
+ return original(...exceptions)(target);
+ };
+ };
+ };
+ }
}
diff --git a/packages/node/src/integrations/tracing/nest/types.ts b/packages/node/src/integrations/tracing/nest/types.ts
index 2cdd1b6aefaf..42aa0b003315 100644
--- a/packages/node/src/integrations/tracing/nest/types.ts
+++ b/packages/node/src/integrations/tracing/nest/types.ts
@@ -55,3 +55,15 @@ export interface InjectableTarget {
intercept?: (context: unknown, next: CallHandler, ...args: any[]) => Observable;
};
}
+
+/**
+ * Represents a target class in NestJS annotated with @Catch.
+ */
+export interface CatchTarget {
+ name: string;
+ sentryPatched?: boolean;
+ __SENTRY_INTERNAL__?: boolean;
+ prototype: {
+ catch?: (...args: any[]) => any;
+ };
+}
diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts
index ef57ab0fff3d..98460b575c8d 100644
--- a/packages/opentelemetry/src/index.ts
+++ b/packages/opentelemetry/src/index.ts
@@ -1,3 +1,5 @@
+export { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from './semanticAttributes';
+
export { getRequestSpanData } from './utils/getRequestSpanData';
export type { OpenTelemetryClient } from './types';
diff --git a/packages/opentelemetry/src/semanticAttributes.ts b/packages/opentelemetry/src/semanticAttributes.ts
index 80a80f87a666..2e14c71bf5e9 100644
--- a/packages/opentelemetry/src/semanticAttributes.ts
+++ b/packages/opentelemetry/src/semanticAttributes.ts
@@ -1,2 +1,5 @@
/** If this attribute is true, it means that the parent is a remote span. */
export const SEMANTIC_ATTRIBUTE_SENTRY_PARENT_IS_REMOTE = 'sentry.parentIsRemote';
+
+// These are not standardized yet, but used by the graphql instrumentation
+export const SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION = 'sentry.graphql.operation';
diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts
index a9d99aa91b8a..6d1c9936899b 100644
--- a/packages/opentelemetry/src/utils/parseSpanDescription.ts
+++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts
@@ -15,6 +15,7 @@ import type { SpanAttributes, TransactionSource } from '@sentry/types';
import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '@sentry/utils';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core';
+import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '../semanticAttributes';
import type { AbstractSpan } from '../types';
import { getSpanKind } from './getSpanKind';
import { spanHasAttributes, spanHasName } from './spanTypes';
@@ -136,8 +137,16 @@ export function descriptionForHttpMethod(
return { op: opParts.join('.'), description: name, source: 'custom' };
}
- // Ex. description="GET /api/users".
- const description = `${httpMethod} ${urlPath}`;
+ const graphqlOperationsAttribute = attributes[SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION];
+
+ // Ex. GET /api/users
+ const baseDescription = `${httpMethod} ${urlPath}`;
+
+ // When the http span has a graphql operation, append it to the description
+ // We add these in the graphqlIntegration
+ const description = graphqlOperationsAttribute
+ ? `${baseDescription} (${getGraphqlOperationNamesFromAttribute(graphqlOperationsAttribute)})`
+ : baseDescription;
// If `httpPath` is a root path, then we can categorize the transaction source as route.
const source: TransactionSource = hasRoute || urlPath === '/' ? 'route' : 'url';
@@ -162,6 +171,22 @@ export function descriptionForHttpMethod(
};
}
+function getGraphqlOperationNamesFromAttribute(attr: AttributeValue): string {
+ if (Array.isArray(attr)) {
+ const sorted = attr.slice().sort();
+
+ // Up to 5 items, we just add all of them
+ if (sorted.length <= 5) {
+ return sorted.join(', ');
+ } else {
+ // Else, we add the first 5 and the diff of other operations
+ return `${sorted.slice(0, 5).join(', ')}, +${sorted.length - 5}`;
+ }
+ }
+
+ return `${attr}`;
+}
+
/** Exported for tests only */
export function getSanitizedUrl(
attributes: Attributes,
diff --git a/packages/opentelemetry/src/utils/spanTypes.ts b/packages/opentelemetry/src/utils/spanTypes.ts
index f92d411200a1..39c62219d2ad 100644
--- a/packages/opentelemetry/src/utils/spanTypes.ts
+++ b/packages/opentelemetry/src/utils/spanTypes.ts
@@ -22,7 +22,7 @@ export function spanHasAttributes(
*/
export function spanHasKind(span: SpanType): span is SpanType & { kind: SpanKind } {
const castSpan = span as ReadableSpan;
- return !!castSpan.kind;
+ return typeof castSpan.kind === 'number';
}
/**
diff --git a/packages/opentelemetry/test/utils/spanTypes.test.ts b/packages/opentelemetry/test/utils/spanTypes.test.ts
index 99152204adfa..af07e5c45af5 100644
--- a/packages/opentelemetry/test/utils/spanTypes.test.ts
+++ b/packages/opentelemetry/test/utils/spanTypes.test.ts
@@ -24,7 +24,9 @@ describe('spanTypes', () => {
it.each([
[{}, false],
[{ kind: null }, false],
- [{ kind: 'TEST_KIND' }, true],
+ [{ kind: 0 }, true],
+ [{ kind: 5 }, true],
+ [{ kind: 'TEST_KIND' }, false],
])('works with %p', (span, expected) => {
const castSpan = span as unknown as Span;
const actual = spanHasKind(castSpan);
diff --git a/packages/remix/src/utils/web-fetch.ts b/packages/remix/src/utils/web-fetch.ts
index 8450a12eb05d..6e188bd9d440 100644
--- a/packages/remix/src/utils/web-fetch.ts
+++ b/packages/remix/src/utils/web-fetch.ts
@@ -1,4 +1,3 @@
-/* eslint-disable complexity */
// Based on Remix's implementation of Fetch API
// https://github.com/remix-run/web-std-io/blob/d2a003fe92096aaf97ab2a618b74875ccaadc280/packages/fetch/
// The MIT License (MIT)
@@ -23,10 +22,6 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-import { logger } from '@sentry/utils';
-
-import { DEBUG_BUILD } from './debug-build';
-import { getClientIPAddress } from './vendor/getIpAddress';
import type { RemixRequest } from './vendor/types';
/*
@@ -124,15 +119,6 @@ export const normalizeRemixRequest = (request: RemixRequest): Record = {};
-
- get(key: string): string | null {
- return this._headers[key] ?? null;
- }
-
- set(key: string, value: string): void {
- this._headers[key] = value;
- }
-}
-
-describe('getClientIPAddress', () => {
- it.each([
- [
- '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5,2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5, 141.101.69.35',
- '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5',
- ],
- [
- '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5, 2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5, 141.101.69.35',
- '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5',
- ],
- [
- '2a01:cb19:8350:ed00:d0dd:INVALID_IP_ADDR:8be5,141.101.69.35,2a01:cb19:8350:ed00:d0dd:fa5b:de31:8be5',
- '141.101.69.35',
- ],
- [
- '2b01:cb19:8350:ed00:d0dd:fa5b:nope:8be5, 2b01:cb19:NOPE:ed00:d0dd:fa5b:de31:8be5, 141.101.69.35 ',
- '141.101.69.35',
- ],
- ['2b01:cb19:8350:ed00:d0 dd:fa5b:de31:8be5, 141.101.69.35', '141.101.69.35'],
- ])('should parse the IP from the X-Forwarded-For header %s', (headerValue, expectedIP) => {
- const headers = new Headers();
- headers.set('X-Forwarded-For', headerValue);
-
- const ip = getClientIPAddress(headers as any);
-
- expect(ip).toEqual(expectedIP);
- });
-});
diff --git a/packages/remix/test/utils/normalizeRemixRequest.test.ts b/packages/remix/test/utils/normalizeRemixRequest.test.ts
index b627a34e4f12..64de88510014 100644
--- a/packages/remix/test/utils/normalizeRemixRequest.test.ts
+++ b/packages/remix/test/utils/normalizeRemixRequest.test.ts
@@ -83,7 +83,6 @@ describe('normalizeRemixRequest', () => {
hostname: 'example.com',
href: 'https://example.com/api/json?id=123',
insecureHTTPParser: undefined,
- ip: null,
method: 'GET',
originalUrl: 'https://example.com/api/json?id=123',
path: '/api/json?id=123',
diff --git a/packages/solidstart/README.md b/packages/solidstart/README.md
index 61aa3b2793da..b654b9bdf744 100644
--- a/packages/solidstart/README.md
+++ b/packages/solidstart/README.md
@@ -161,3 +161,59 @@ render(
document.getElementById('root'),
);
```
+
+# Sourcemaps and Releases
+
+To generate and upload source maps of your Solid Start app use our Vite bundler plugin.
+
+1. Install the Sentry Vite plugin
+
+```bash
+# Using npm
+npm install @sentry/vite-plugin --save-dev
+
+# Using yarn
+yarn add @sentry/vite-plugin --dev
+```
+
+2. Configure the vite plugin
+
+To upload source maps you have to configure an auth token. Auth tokens can be passed to the plugin explicitly with the
+`authToken` option, with a `SENTRY_AUTH_TOKEN` environment variable, or with an `.env.sentry-build-plugin` file in the
+working directory when building your project. We recommend you add the auth token to your CI/CD environment as an
+environment variable.
+
+Learn more about configuring the plugin in our
+[Sentry Vite Plugin documentation](https://www.npmjs.com/package/@sentry/vite-plugin).
+
+```bash
+// .env.sentry-build-plugin
+SENTRY_AUTH_TOKEN=
+SENTRY_ORG=
+SENTRY_PROJECT=
+```
+
+3. Finally, add the plugin to your `app.config.ts` file.
+
+```javascript
+import { defineConfig } from '@solidjs/start/config';
+import { sentryVitePlugin } from '@sentry/vite-plugin';
+
+export default defineConfig({
+ // rest of your config
+ // ...
+
+ vite: {
+ build: {
+ sourcemap: true,
+ },
+ plugins: [
+ sentryVitePlugin({
+ org: process.env.SENTRY_ORG,
+ project: process.env.SENTRY_PROJECT,
+ authToken: process.env.SENTRY_AUTH_TOKEN,
+ }),
+ ],
+ },
+});
+```
diff --git a/packages/solidstart/src/middleware.ts b/packages/solidstart/src/middleware.ts
index 0113cce8f988..65287d23fa0b 100644
--- a/packages/solidstart/src/middleware.ts
+++ b/packages/solidstart/src/middleware.ts
@@ -1,4 +1,4 @@
-import { getTraceData } from '@sentry/core';
+import { getTraceMetaTags } from '@sentry/core';
import { addNonEnumerableProperty } from '@sentry/utils';
import type { ResponseMiddleware } from '@solidjs/start/middleware';
import type { FetchEvent } from '@solidjs/start/server';
@@ -8,19 +8,13 @@ export type ResponseMiddlewareResponse = Parameters[1] & {
};
function addMetaTagToHead(html: string): string {
- const { 'sentry-trace': sentryTrace, baggage } = getTraceData();
+ const metaTags = getTraceMetaTags();
- if (!sentryTrace) {
+ if (!metaTags) {
return html;
}
- const metaTags = [``];
-
- if (baggage) {
- metaTags.push(``);
- }
-
- const content = `\n${metaTags.join('\n')}\n`;
+ const content = `\n${metaTags}\n`;
return html.replace('', content);
}
diff --git a/packages/solidstart/test/middleware.test.ts b/packages/solidstart/test/middleware.test.ts
index 888a0fbc702d..c025dc38af97 100644
--- a/packages/solidstart/test/middleware.test.ts
+++ b/packages/solidstart/test/middleware.test.ts
@@ -5,10 +5,10 @@ import type { ResponseMiddlewareResponse } from '../src/middleware';
describe('middleware', () => {
describe('sentryBeforeResponseMiddleware', () => {
- vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({
- 'sentry-trace': '123',
- baggage: 'abc',
- });
+ vi.spyOn(SentryCore, 'getTraceMetaTags').mockReturnValue(`
+ ,
+
+ `);
const mockFetchEvent = {
request: {},
diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts
index 56ddc23e1885..7f4b8d980ad8 100644
--- a/packages/sveltekit/src/server/handle.ts
+++ b/packages/sveltekit/src/server/handle.ts
@@ -11,21 +11,15 @@ import {
withIsolationScope,
} from '@sentry/core';
import { startSpan } from '@sentry/core';
-import { captureException, continueTrace } from '@sentry/node';
+import { continueTrace } from '@sentry/node';
import type { Span } from '@sentry/types';
-import {
- dynamicSamplingContextToSentryBaggageHeader,
- logger,
- objectify,
- winterCGRequestToRequestData,
-} from '@sentry/utils';
+import { dynamicSamplingContextToSentryBaggageHeader, logger, winterCGRequestToRequestData } from '@sentry/utils';
import type { Handle, ResolveOptions } from '@sveltejs/kit';
import { getDynamicSamplingContextFromSpan } from '@sentry/opentelemetry';
import { DEBUG_BUILD } from '../common/debug-build';
-import { isHttpError, isRedirect } from '../common/utils';
-import { flushIfServerless, getTracePropagationData } from './utils';
+import { flushIfServerless, getTracePropagationData, sendErrorToSentry } from './utils';
export type SentryHandleOptions = {
/**
@@ -62,32 +56,6 @@ export type SentryHandleOptions = {
fetchProxyScriptNonce?: string;
};
-function sendErrorToSentry(e: unknown): unknown {
- // 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.
- const objectifiedErr = objectify(e);
-
- // similarly to the `load` function, we don't want to capture 4xx errors or redirects
- if (
- isRedirect(objectifiedErr) ||
- (isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
- ) {
- return objectifiedErr;
- }
-
- captureException(objectifiedErr, {
- mechanism: {
- type: 'sveltekit',
- handled: false,
- data: {
- function: 'handle',
- },
- },
- });
-
- return objectifiedErr;
-}
-
/**
* Exported only for testing
*/
@@ -225,7 +193,7 @@ async function instrumentHandle(
);
return resolveResult;
} catch (e: unknown) {
- sendErrorToSentry(e);
+ sendErrorToSentry(e, 'handle');
throw e;
} finally {
await flushIfServerless();
diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts
index a74e5bb89dc0..32dd6627d7a6 100644
--- a/packages/sveltekit/src/server/index.ts
+++ b/packages/sveltekit/src/server/index.ts
@@ -52,6 +52,7 @@ export {
getSpanDescendants,
getSpanStatusFromHttpCode,
getTraceData,
+ getTraceMetaTags,
graphqlIntegration,
hapiIntegration,
httpIntegration,
@@ -128,6 +129,7 @@ export { init } from './sdk';
export { handleErrorWithSentry } from './handleError';
export { wrapLoadWithSentry, wrapServerLoadWithSentry } from './load';
export { sentryHandle } from './handle';
+export { wrapServerRouteWithSentry } from './serverRoute';
/**
* Tracks the Svelte component's initialization and mounting operation as well as
diff --git a/packages/sveltekit/src/server/load.ts b/packages/sveltekit/src/server/load.ts
index fe61ed0913bd..82a8c548c6ec 100644
--- a/packages/sveltekit/src/server/load.ts
+++ b/packages/sveltekit/src/server/load.ts
@@ -1,49 +1,13 @@
-import {
- SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
- SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
- captureException,
- startSpan,
-} from '@sentry/node';
-import { addNonEnumerableProperty, objectify } from '@sentry/utils';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node';
+import { addNonEnumerableProperty } from '@sentry/utils';
import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit';
import type { SentryWrappedFlag } from '../common/utils';
-import { isHttpError, isRedirect } from '../common/utils';
-import { flushIfServerless } from './utils';
+import { flushIfServerless, sendErrorToSentry } from './utils';
type PatchedLoadEvent = LoadEvent & SentryWrappedFlag;
type PatchedServerLoadEvent = ServerLoadEvent & SentryWrappedFlag;
-function sendErrorToSentry(e: unknown): unknown {
- // 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.
- const objectifiedErr = objectify(e);
-
- // The error() helper is commonly used to throw errors in load functions: https://kit.svelte.dev/docs/modules#sveltejs-kit-error
- // If we detect a thrown error that is an instance of HttpError, we don't want to capture 4xx errors as they
- // could be noisy.
- // Also the `redirect(...)` helper is used to redirect users from one page to another. We don't want to capture thrown
- // `Redirect`s as they're not errors but expected behaviour
- if (
- isRedirect(objectifiedErr) ||
- (isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
- ) {
- return objectifiedErr;
- }
-
- captureException(objectifiedErr, {
- mechanism: {
- type: 'sveltekit',
- handled: false,
- data: {
- function: 'load',
- },
- },
- });
-
- return objectifiedErr;
-}
-
/**
* @inheritdoc
*/
@@ -81,7 +45,7 @@ export function wrapLoadWithSentry any>(origLoad: T)
() => wrappingTarget.apply(thisArg, args),
);
} catch (e) {
- sendErrorToSentry(e);
+ sendErrorToSentry(e, 'load');
throw e;
} finally {
await flushIfServerless();
@@ -146,7 +110,7 @@ export function wrapServerLoadWithSentry any>(origSe
() => wrappingTarget.apply(thisArg, args),
);
} catch (e: unknown) {
- sendErrorToSentry(e);
+ sendErrorToSentry(e, 'load');
throw e;
} finally {
await flushIfServerless();
diff --git a/packages/sveltekit/src/server/serverRoute.ts b/packages/sveltekit/src/server/serverRoute.ts
new file mode 100644
index 000000000000..a5f13f9a73ca
--- /dev/null
+++ b/packages/sveltekit/src/server/serverRoute.ts
@@ -0,0 +1,67 @@
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node';
+import { addNonEnumerableProperty } from '@sentry/utils';
+import type { RequestEvent } from '@sveltejs/kit';
+import { flushIfServerless, sendErrorToSentry } from './utils';
+
+type PatchedServerRouteEvent = RequestEvent & { __sentry_wrapped__?: boolean };
+
+/**
+ * Wraps a server route handler for API or server routes registered in `+server.(js|js)` files.
+ *
+ * This function will automatically capture any errors that occur during the execution of the route handler
+ * and it will start a span for the duration of your route handler.
+ *
+ * @example
+ * ```js
+ * import { wrapServerRouteWithSentry } from '@sentry/sveltekit';
+ *
+ * const get = async event => {
+ * return new Response(JSON.stringify({ message: 'hello world' }));
+ * }
+ *
+ * export const GET = wrapServerRouteWithSentry(get);
+ * ```
+ *
+ * @param originalRouteHandler your server route handler
+ * @param httpMethod the HTTP method of your route handler
+ *
+ * @returns a wrapped version of your server route handler
+ */
+export function wrapServerRouteWithSentry(
+ originalRouteHandler: (request: RequestEvent) => Promise,
+): (requestEvent: RequestEvent) => Promise {
+ return new Proxy(originalRouteHandler, {
+ apply: async (wrappingTarget, thisArg, args) => {
+ const event = args[0] as PatchedServerRouteEvent;
+
+ if (event.__sentry_wrapped__) {
+ return wrappingTarget.apply(thisArg, args);
+ }
+
+ const routeId = event.route && event.route.id;
+ const httpMethod = event.request.method;
+
+ addNonEnumerableProperty(event as unknown as Record, '__sentry_wrapped__', true);
+
+ try {
+ return await startSpan(
+ {
+ name: `${httpMethod} ${routeId || 'Server Route'}`,
+ op: `function.sveltekit.server.${httpMethod.toLowerCase()}`,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ },
+ onlyIfParent: true,
+ },
+ () => wrappingTarget.apply(thisArg, args),
+ );
+ } catch (e) {
+ sendErrorToSentry(e, 'serverRoute');
+ throw e;
+ } finally {
+ await flushIfServerless();
+ }
+ },
+ });
+}
diff --git a/packages/sveltekit/src/server/utils.ts b/packages/sveltekit/src/server/utils.ts
index 0db8ee893783..8d7f2c649331 100644
--- a/packages/sveltekit/src/server/utils.ts
+++ b/packages/sveltekit/src/server/utils.ts
@@ -1,8 +1,9 @@
-import { flush } from '@sentry/node';
-import { logger } from '@sentry/utils';
+import { captureException, flush } from '@sentry/node';
+import { logger, objectify } from '@sentry/utils';
import type { RequestEvent } from '@sveltejs/kit';
import { DEBUG_BUILD } from '../common/debug-build';
+import { isHttpError, isRedirect } from '../common/utils';
/**
* Takes a request event and extracts traceparent and DSC data
@@ -31,3 +32,41 @@ export async function flushIfServerless(): Promise {
}
}
}
+
+/**
+ * Extracts a server-side sveltekit error, filters a couple of known errors we don't want to capture
+ * and captures the error via `captureException`.
+ *
+ * @param e error
+ *
+ * @returns an objectified version of @param e
+ */
+export function sendErrorToSentry(e: unknown, handlerFn: 'handle' | 'load' | 'serverRoute'): object {
+ // 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.
+ const objectifiedErr = objectify(e);
+
+ // The error() helper is commonly used to throw errors in load functions: https://kit.svelte.dev/docs/modules#sveltejs-kit-error
+ // If we detect a thrown error that is an instance of HttpError, we don't want to capture 4xx errors as they
+ // could be noisy.
+ // Also the `redirect(...)` helper is used to redirect users from one page to another. We don't want to capture thrown
+ // `Redirect`s as they're not errors but expected behaviour
+ if (
+ isRedirect(objectifiedErr) ||
+ (isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
+ ) {
+ return objectifiedErr;
+ }
+
+ captureException(objectifiedErr, {
+ mechanism: {
+ type: 'sveltekit',
+ handled: false,
+ data: {
+ function: handlerFn,
+ },
+ },
+ });
+
+ return objectifiedErr;
+}
diff --git a/packages/sveltekit/test/server/serverRoute.test.ts b/packages/sveltekit/test/server/serverRoute.test.ts
new file mode 100644
index 000000000000..de99db5a548e
--- /dev/null
+++ b/packages/sveltekit/test/server/serverRoute.test.ts
@@ -0,0 +1,133 @@
+import * as SentryNode from '@sentry/node';
+import type { NumericRange } from '@sveltejs/kit';
+import { type RequestEvent, error, redirect } from '@sveltejs/kit';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ wrapServerRouteWithSentry,
+} from '../../src/server';
+
+describe('wrapServerRouteWithSentry', () => {
+ const originalRouteHandler = vi.fn();
+
+ const getRequestEventMock = () =>
+ ({
+ request: {
+ method: 'GET',
+ },
+ route: {
+ id: '/api/users/:id',
+ },
+ }) as RequestEvent;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('wraps a server route span around the original server route handler', () => {
+ const startSpanSpy = vi.spyOn(SentryNode, 'startSpan');
+
+ it('assigns the route id as name if available', () => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(originalRouteHandler);
+
+ wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ },
+ name: 'GET /api/users/:id',
+ onlyIfParent: true,
+ op: 'function.sveltekit.server.get',
+ },
+ expect.any(Function),
+ );
+
+ expect(originalRouteHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to a generic name if route id is not available', () => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(originalRouteHandler);
+
+ wrappedRouteHandler({ ...getRequestEventMock(), route: undefined } as unknown as RequestEvent);
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ },
+ name: 'GET Server Route',
+ onlyIfParent: true,
+ op: 'function.sveltekit.server.get',
+ },
+ expect.any(Function),
+ );
+
+ expect(originalRouteHandler).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException');
+ describe('captures server route errors', () => {
+ it('captures and rethrows normal server route error', async () => {
+ const error = new Error('Server Route Error');
+
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ throw error;
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrowError('Server Route Error');
+
+ expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
+ mechanism: { type: 'sveltekit', handled: false, data: { function: 'serverRoute' } },
+ });
+ });
+
+ it.each([500, 501, 599])('captures and rethrows %s error() calls', async status => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ error(status as NumericRange<400, 599>, `error(${status}) error`);
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrow();
+
+ expect(captureExceptionSpy).toHaveBeenCalledWith(
+ { body: { message: `error(${status}) error` }, status },
+ {
+ mechanism: { type: 'sveltekit', handled: false, data: { function: 'serverRoute' } },
+ },
+ );
+ });
+
+ it.each([400, 401, 499])("doesn't capture but rethrows %s error() calls", async status => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ error(status as NumericRange<400, 599>, `error(${status}) error`);
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrow();
+
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
+ });
+
+ it.each([300, 301, 308])("doesn't capture redirect(%s) calls", async status => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ redirect(status as NumericRange<300, 308>, '/redirect');
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrow();
+
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/utils/src/requestdata.ts b/packages/utils/src/requestdata.ts
index 85133521ef76..a4eae547edb1 100644
--- a/packages/utils/src/requestdata.ts
+++ b/packages/utils/src/requestdata.ts
@@ -13,6 +13,7 @@ import { isPlainObject, isString } from './is';
import { logger } from './logger';
import { normalize } from './normalize';
import { stripUrlQueryAndFragment } from './url';
+import { getClientIPAddress, ipHeaderNames } from './vendor/getIpAddress';
const DEFAULT_INCLUDES = {
ip: false,
@@ -98,7 +99,6 @@ export function extractPathForTransaction(
return [name, source];
}
-/** JSDoc */
function extractTransaction(req: PolymorphicRequest, type: boolean | TransactionNamingScheme): string {
switch (type) {
case 'path': {
@@ -116,7 +116,6 @@ function extractTransaction(req: PolymorphicRequest, type: boolean | Transaction
}
}
-/** JSDoc */
function extractUserData(
user: {
[key: string]: unknown;
@@ -146,17 +145,16 @@ function extractUserData(
*/
export function extractRequestData(
req: PolymorphicRequest,
- options?: {
+ options: {
include?: string[];
- },
+ } = {},
): ExtractedNodeRequestData {
- const { include = DEFAULT_REQUEST_INCLUDES } = options || {};
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const requestData: { [key: string]: any } = {};
+ const { include = DEFAULT_REQUEST_INCLUDES } = options;
+ const requestData: { [key: string]: unknown } = {};
// headers:
// node, express, koa, nextjs: req.headers
- const headers = (req.headers || {}) as {
+ const headers = (req.headers || {}) as typeof req.headers & {
host?: string;
cookie?: string;
};
@@ -191,6 +189,14 @@ export function extractRequestData(
delete (requestData.headers as { cookie?: string }).cookie;
}
+ // Remove IP headers in case IP data should not be included in the event
+ if (!include.includes('ip')) {
+ ipHeaderNames.forEach(ipHeaderName => {
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+ delete (requestData.headers as Record)[ipHeaderName];
+ });
+ }
+
break;
}
case 'method': {
@@ -264,9 +270,12 @@ export function addRequestDataToEvent(
};
if (include.request) {
- const extractedRequestData = Array.isArray(include.request)
- ? extractRequestData(req, { include: include.request })
- : extractRequestData(req);
+ const includeRequest = Array.isArray(include.request) ? [...include.request] : [...DEFAULT_REQUEST_INCLUDES];
+ if (include.ip) {
+ includeRequest.push('ip');
+ }
+
+ const extractedRequestData = extractRequestData(req, { include: includeRequest });
event.request = {
...event.request,
@@ -288,8 +297,9 @@ export function addRequestDataToEvent(
// client ip:
// node, nextjs: req.socket.remoteAddress
// express, koa: req.ip
+ // It may also be sent by proxies as specified in X-Forwarded-For or similar headers
if (include.ip) {
- const ip = req.ip || (req.socket && req.socket.remoteAddress);
+ const ip = (req.headers && getClientIPAddress(req.headers)) || req.ip || (req.socket && req.socket.remoteAddress);
if (ip) {
event.user = {
...event.user,
diff --git a/packages/remix/src/utils/vendor/getIpAddress.ts b/packages/utils/src/vendor/getIpAddress.ts
similarity index 50%
rename from packages/remix/src/utils/vendor/getIpAddress.ts
rename to packages/utils/src/vendor/getIpAddress.ts
index d63e31779aac..8b96fe2146af 100644
--- a/packages/remix/src/utils/vendor/getIpAddress.ts
+++ b/packages/utils/src/vendor/getIpAddress.ts
@@ -23,7 +23,21 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-import { isIP } from 'net';
+// The headers to check, in priority order
+export const ipHeaderNames = [
+ 'X-Client-IP',
+ 'X-Forwarded-For',
+ 'Fly-Client-IP',
+ 'CF-Connecting-IP',
+ 'Fastly-Client-Ip',
+ 'True-Client-Ip',
+ 'X-Real-IP',
+ 'X-Cluster-Client-IP',
+ 'X-Forwarded',
+ 'Forwarded-For',
+ 'Forwarded',
+ 'X-Vercel-Forwarded-For',
+];
/**
* Get the IP address of the client sending a request.
@@ -31,50 +45,24 @@ import { isIP } from 'net';
* It receives a Request headers object and use it to get the
* IP address from one of the following headers in order.
*
- * - X-Client-IP
- * - X-Forwarded-For
- * - Fly-Client-IP
- * - CF-Connecting-IP
- * - Fastly-Client-Ip
- * - True-Client-Ip
- * - X-Real-IP
- * - X-Cluster-Client-IP
- * - X-Forwarded
- * - Forwarded-For
- * - Forwarded
- *
* If the IP address is valid, it will be returned. Otherwise, null will be
* returned.
*
* If the header values contains more than one IP address, the first valid one
* will be returned.
*/
-export function getClientIPAddress(headers: Headers): string | null {
- // The headers to check, in priority order
- const headerNames = [
- 'X-Client-IP',
- 'X-Forwarded-For',
- 'Fly-Client-IP',
- 'CF-Connecting-IP',
- 'Fastly-Client-Ip',
- 'True-Client-Ip',
- 'X-Real-IP',
- 'X-Cluster-Client-IP',
- 'X-Forwarded',
- 'Forwarded-For',
- 'Forwarded',
- ];
-
+export function getClientIPAddress(headers: { [key: string]: string | string[] | undefined }): string | null {
// This will end up being Array because of the various possible values a header
// can take
- const headerValues = headerNames.map((headerName: string) => {
- const value = headers.get(headerName);
+ const headerValues = ipHeaderNames.map((headerName: string) => {
+ const rawValue = headers[headerName];
+ const value = Array.isArray(rawValue) ? rawValue.join(';') : rawValue;
if (headerName === 'Forwarded') {
return parseForwardedHeader(value);
}
- return value?.split(',').map((v: string) => v.trim());
+ return value && value.split(',').map((v: string) => v.trim());
});
// Flatten the array and filter out any falsy entries
@@ -92,7 +80,7 @@ export function getClientIPAddress(headers: Headers): string | null {
return ipAddress || null;
}
-function parseForwardedHeader(value: string | null): string | null {
+function parseForwardedHeader(value: string | null | undefined): string | null {
if (!value) {
return null;
}
@@ -105,3 +93,31 @@ function parseForwardedHeader(value: string | null): string | null {
return null;
}
+
+//
+/**
+ * Custom method instead of importing this from `net` package, as this only exists in node
+ * Accepts:
+ * 127.0.0.1
+ * 192.168.1.1
+ * 192.168.1.255
+ * 255.255.255.255
+ * 10.1.1.1
+ * 0.0.0.0
+ * 2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5
+ *
+ * Rejects:
+ * 1.1.1.01
+ * 30.168.1.255.1
+ * 127.1
+ * 192.168.1.256
+ * -1.2.3.4
+ * 1.1.1.1.
+ * 3...3
+ * 192.168.1.099
+ */
+function isIP(str: string): boolean {
+ const regex =
+ /(?:^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$)|(?:^(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?$)/;
+ return regex.test(str);
+}
diff --git a/packages/utils/test/requestdata.test.ts b/packages/utils/test/requestdata.test.ts
index 7e44f703c62a..570f80647b6b 100644
--- a/packages/utils/test/requestdata.test.ts
+++ b/packages/utils/test/requestdata.test.ts
@@ -1,6 +1,7 @@
import type * as net from 'net';
import type { Event, PolymorphicRequest, TransactionSource, User } from '@sentry/types';
import { addRequestDataToEvent, extractPathForTransaction, extractRequestData } from '@sentry/utils';
+import { getClientIPAddress } from '../src/vendor/getIpAddress';
describe('addRequestDataToEvent', () => {
let mockEvent: Event;
@@ -107,6 +108,227 @@ describe('addRequestDataToEvent', () => {
expect(parsedRequest.user!.ip_address).toEqual('321');
});
+
+ test.each([
+ 'X-Client-IP',
+ 'X-Forwarded-For',
+ 'Fly-Client-IP',
+ 'CF-Connecting-IP',
+ 'Fastly-Client-Ip',
+ 'True-Client-Ip',
+ 'X-Real-IP',
+ 'X-Cluster-Client-IP',
+ 'X-Forwarded',
+ 'Forwarded-For',
+ 'X-Vercel-Forwarded-For',
+ ])('can be extracted from %s header', headerName => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ [headerName]: '123.5.6.1',
+ },
+ };
+
+ const optionsWithIP = {
+ include: {
+ ip: true,
+ },
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithIP);
+
+ expect(parsedRequest.user!.ip_address).toEqual('123.5.6.1');
+ });
+
+ it('can be extracted from Forwarded header', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ Forwarded: 'by=111;for=123.5.6.1;for=123.5.6.2;',
+ },
+ };
+
+ const optionsWithIP = {
+ include: {
+ ip: true,
+ },
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithIP);
+
+ expect(parsedRequest.user!.ip_address).toEqual('123.5.6.1');
+ });
+
+ test('it ignores invalid IP in header', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ 'X-Client-IP': 'invalid',
+ },
+ };
+
+ const optionsWithIP = {
+ include: {
+ ip: true,
+ },
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithIP);
+
+ expect(parsedRequest.user!.ip_address).toEqual(undefined);
+ });
+
+ test('IP from header takes presedence over socket', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ 'X-Client-IP': '123.5.6.1',
+ },
+ socket: {
+ remoteAddress: '321',
+ } as net.Socket,
+ };
+
+ const optionsWithIP = {
+ include: {
+ ip: true,
+ },
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithIP);
+
+ expect(parsedRequest.user!.ip_address).toEqual('123.5.6.1');
+ });
+
+ test('IP from header takes presedence over req.ip', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ 'X-Client-IP': '123.5.6.1',
+ },
+ ip: '123',
+ };
+
+ const optionsWithIP = {
+ include: {
+ ip: true,
+ },
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithIP);
+
+ expect(parsedRequest.user!.ip_address).toEqual('123.5.6.1');
+ });
+
+ test('does not add IP if ip=false', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ 'X-Client-IP': '123.5.6.1',
+ },
+ ip: '123',
+ };
+
+ const optionsWithoutIP = {
+ include: {
+ ip: false,
+ },
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithoutIP);
+
+ expect(parsedRequest.user!.ip_address).toEqual(undefined);
+ });
+
+ test('does not add IP by default', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ 'X-Client-IP': '123.5.6.1',
+ },
+ ip: '123',
+ };
+
+ const optionsWithoutIP = {};
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithoutIP);
+
+ expect(parsedRequest.user!.ip_address).toEqual(undefined);
+ });
+
+ test('removes IP headers if `ip` is not set in the options', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ otherHeader: 'hello',
+ 'X-Client-IP': '123',
+ 'X-Forwarded-For': '123',
+ 'Fly-Client-IP': '123',
+ 'CF-Connecting-IP': '123',
+ 'Fastly-Client-Ip': '123',
+ 'True-Client-Ip': '123',
+ 'X-Real-IP': '123',
+ 'X-Cluster-Client-IP': '123',
+ 'X-Forwarded': '123',
+ 'Forwarded-For': '123',
+ Forwarded: '123',
+ 'X-Vercel-Forwarded-For': '123',
+ },
+ };
+
+ const optionsWithoutIP = {
+ include: {},
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithoutIP);
+
+ expect(parsedRequest.request?.headers).toEqual({ otherHeader: 'hello' });
+ });
+
+ test('keeps IP headers if `ip=true`', () => {
+ const reqWithIPInHeader = {
+ ...mockReq,
+ headers: {
+ otherHeader: 'hello',
+ 'X-Client-IP': '123',
+ 'X-Forwarded-For': '123',
+ 'Fly-Client-IP': '123',
+ 'CF-Connecting-IP': '123',
+ 'Fastly-Client-Ip': '123',
+ 'True-Client-Ip': '123',
+ 'X-Real-IP': '123',
+ 'X-Cluster-Client-IP': '123',
+ 'X-Forwarded': '123',
+ 'Forwarded-For': '123',
+ Forwarded: '123',
+ 'X-Vercel-Forwarded-For': '123',
+ },
+ };
+
+ const optionsWithoutIP = {
+ include: {
+ ip: true,
+ },
+ };
+
+ const parsedRequest: Event = addRequestDataToEvent(mockEvent, reqWithIPInHeader, optionsWithoutIP);
+
+ expect(parsedRequest.request?.headers).toEqual({
+ otherHeader: 'hello',
+ 'X-Client-IP': '123',
+ 'X-Forwarded-For': '123',
+ 'Fly-Client-IP': '123',
+ 'CF-Connecting-IP': '123',
+ 'Fastly-Client-Ip': '123',
+ 'True-Client-Ip': '123',
+ 'X-Real-IP': '123',
+ 'X-Cluster-Client-IP': '123',
+ 'X-Forwarded': '123',
+ 'Forwarded-For': '123',
+ Forwarded: '123',
+ 'X-Vercel-Forwarded-For': '123',
+ });
+ });
});
describe('request properties', () => {
@@ -269,6 +491,70 @@ describe('extractRequestData', () => {
cookies: { foo: 'bar' },
});
});
+
+ it('removes IP-related headers from requestdata.headers, if `ip` is not set in the options', () => {
+ const mockReq = {
+ headers: {
+ otherHeader: 'hello',
+ 'X-Client-IP': '123',
+ 'X-Forwarded-For': '123',
+ 'Fly-Client-IP': '123',
+ 'CF-Connecting-IP': '123',
+ 'Fastly-Client-Ip': '123',
+ 'True-Client-Ip': '123',
+ 'X-Real-IP': '123',
+ 'X-Cluster-Client-IP': '123',
+ 'X-Forwarded': '123',
+ 'Forwarded-For': '123',
+ Forwarded: '123',
+ 'X-Vercel-Forwarded-For': '123',
+ },
+ };
+ const options = { include: ['headers'] };
+
+ expect(extractRequestData(mockReq, options)).toStrictEqual({
+ headers: { otherHeader: 'hello' },
+ });
+ });
+
+ it('keeps IP-related headers from requestdata.headers, if `ip` is enabled in options', () => {
+ const mockReq = {
+ headers: {
+ otherHeader: 'hello',
+ 'X-Client-IP': '123',
+ 'X-Forwarded-For': '123',
+ 'Fly-Client-IP': '123',
+ 'CF-Connecting-IP': '123',
+ 'Fastly-Client-Ip': '123',
+ 'True-Client-Ip': '123',
+ 'X-Real-IP': '123',
+ 'X-Cluster-Client-IP': '123',
+ 'X-Forwarded': '123',
+ 'Forwarded-For': '123',
+ Forwarded: '123',
+ 'X-Vercel-Forwarded-For': '123',
+ },
+ };
+ const options = { include: ['headers', 'ip'] };
+
+ expect(extractRequestData(mockReq, options)).toStrictEqual({
+ headers: {
+ otherHeader: 'hello',
+ 'X-Client-IP': '123',
+ 'X-Forwarded-For': '123',
+ 'Fly-Client-IP': '123',
+ 'CF-Connecting-IP': '123',
+ 'Fastly-Client-Ip': '123',
+ 'True-Client-Ip': '123',
+ 'X-Real-IP': '123',
+ 'X-Cluster-Client-IP': '123',
+ 'X-Forwarded': '123',
+ 'Forwarded-For': '123',
+ Forwarded: '123',
+ 'X-Vercel-Forwarded-For': '123',
+ },
+ });
+ });
});
describe('cookies', () => {
@@ -502,3 +788,33 @@ describe('extractPathForTransaction', () => {
expect(source).toEqual('route');
});
});
+
+describe('getClientIPAddress', () => {
+ it.each([
+ [
+ '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5,2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5, 141.101.69.35',
+ '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5',
+ ],
+ [
+ '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5, 2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5, 141.101.69.35',
+ '2b01:cb19:8350:ed00:d0dd:fa5b:de31:8be5',
+ ],
+ [
+ '2a01:cb19:8350:ed00:d0dd:INVALID_IP_ADDR:8be5,141.101.69.35,2a01:cb19:8350:ed00:d0dd:fa5b:de31:8be5',
+ '141.101.69.35',
+ ],
+ [
+ '2b01:cb19:8350:ed00:d0dd:fa5b:nope:8be5, 2b01:cb19:NOPE:ed00:d0dd:fa5b:de31:8be5, 141.101.69.35 ',
+ '141.101.69.35',
+ ],
+ ['2b01:cb19:8350:ed00:d0 dd:fa5b:de31:8be5, 141.101.69.35', '141.101.69.35'],
+ ])('should parse the IP from the X-Forwarded-For header %s', (headerValue, expectedIP) => {
+ const headers = {
+ 'X-Forwarded-For': headerValue,
+ };
+
+ const ip = getClientIPAddress(headers);
+
+ expect(ip).toEqual(expectedIP);
+ });
+});
diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts
index a96fc15e35d2..8be93345fa3d 100644
--- a/packages/vercel-edge/src/index.ts
+++ b/packages/vercel-edge/src/index.ts
@@ -56,6 +56,7 @@ export {
getActiveSpan,
getRootSpan,
getTraceData,
+ getTraceMetaTags,
startSpan,
startInactiveSpan,
startSpanManual,