From 8682df046574622ece3e37d710ed641cce489c12 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 16 Apr 2025 14:29:22 +0200 Subject: [PATCH 01/19] test(node): Increase timeout of anr tests (#16076) Noticed this flaking sometimes, not 100% sure if this is due to the timeout, but it does not hurt I guess.. --- dev-packages/node-integration-tests/suites/anr/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index ad3647ec3974..5bee31aa571c 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -107,7 +107,7 @@ const ANR_EVENT_WITH_DEBUG_META: Event = { }, }; -describe('should report ANR when event loop blocked', () => { +describe('should report ANR when event loop blocked', { timeout: 60_000 }, () => { afterAll(() => { cleanupChildProcesses(); }); From 056a8651645121002604b0866d6bdf94fa99d0c0 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 16 Apr 2025 14:29:37 +0200 Subject: [PATCH 02/19] ci: Fix issue labelling with multiple labels (#16072) Oops, the change I made seems to not work (https://github.com/getsentry/sentry-javascript/actions/runs/14487310833/job/40635465597#step:3:159). This should hopefully fix this by splitting this into two steps. --- .github/workflows/issue-package-label.yml | 30 +++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index bcec195ffa5e..3c96b456b34d 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -120,10 +120,10 @@ jobs: "label": "WASM" }, "Sentry.Browser.Loader": { - "label": "Browser\nLoader Script" + "label": "Browser" }, "Sentry.Browser.CDN.bundle": { - "label": "Browser\nCDN Bundle" + "label": "Browser" } } export_to: output @@ -134,3 +134,29 @@ jobs: uses: actions-ecosystem/action-add-labels@v1 with: labels: ${{ steps.packageLabel.outputs.label }} + + - name: Map additional to issue label + # https://github.com/kanga333/variable-mapper + uses: kanga333/variable-mapper@v0.3.0 + id: additionalLabel + if: steps.packageName.outputs.match != '' + with: + key: '${{ steps.packageName.outputs.group1 }}' + # Note: Since this is handled as a regex, and JSON parse wrangles slashes /, we just use `.` instead + map: | + { + "Sentry.Browser.Loader": { + "label": "Browser" + }, + "Sentry.Browser.CDN.bundle": { + "label": "CDN Bundle" + } + } + export_to: output + + - name: Add additional label if applicable + # Note: We only add the label if the issue is still open + if: steps.additionalLabel.outputs.label != '' + uses: actions-ecosystem/action-add-labels@v1 + with: + labels: ${{ steps.packageLabel.outputs.label }} From 9f9a6c10221d50d7b7c29ed8d2ce9d4b763657fa Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 16 Apr 2025 14:47:24 +0200 Subject: [PATCH 03/19] ci: Finally really fix issue labels (#16080) Another try, hopefully that was it then... --- .github/workflows/issue-package-label.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index 3c96b456b34d..d432589e24e0 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -44,7 +44,7 @@ jobs: "@sentry.bun": { "label": "Bun" }, - "@sentry.cloudflare - hono": { + "@sentry.cloudflare.-.hono": { "label": "Hono" }, "@sentry.cloudflare": { @@ -68,19 +68,19 @@ jobs: "@sentry.nextjs": { "label": "Next.js" }, - "@sentry.node - express": { + "@sentry.node.-.express": { "label": "Express" }, - "@sentry.node - fastify": { + "@sentry.node.-.fastify": { "label": "Fastify" }, - "@sentry.node - koa": { + "@sentry.node.-.koa": { "label": "Koa" }, - "@sentry.node - hapi": { + "@sentry.node.-.hapi": { "label": "Hapi }, - "@sentry.node - connect": { + "@sentry.node.-.connect": { "label": "Connect }, "@sentry.node": { @@ -146,7 +146,7 @@ jobs: map: | { "Sentry.Browser.Loader": { - "label": "Browser" + "label": "Loader Script" }, "Sentry.Browser.CDN.bundle": { "label": "CDN Bundle" From e066a4b4f04743f767d7ce7c613cb52e6466979f Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 16 Apr 2025 15:40:28 +0200 Subject: [PATCH 04/19] ci: Finally fix it for real real (#16082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OK I looked for the error in the wrong place, it was in the JSON 🤦 I verified that it works now... --- .github/workflows/issue-package-label.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index d432589e24e0..16199b4d33b8 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -78,10 +78,10 @@ jobs: "label": "Koa" }, "@sentry.node.-.hapi": { - "label": "Hapi + "label": "Hapi" }, "@sentry.node.-.connect": { - "label": "Connect + "label": "Connect" }, "@sentry.node": { "label": "Node.js" @@ -90,7 +90,7 @@ jobs: "label": "Nuxt" }, "@sentry.react-router": { - "label": "React Router Framework " + "label": "React Router Framework" }, "@sentry.react": { "label": "React" From 3bc192315d73f0e2058c70c46500da738dfe0d32 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 16 Apr 2025 16:20:34 +0200 Subject: [PATCH 05/19] ci: Fix additional label (#16085) OK, general label adding works now, but the additional labels had a typo, oops... --- .github/workflows/issue-package-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index 16199b4d33b8..ef0f0344b8fc 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -159,4 +159,4 @@ jobs: if: steps.additionalLabel.outputs.label != '' uses: actions-ecosystem/action-add-labels@v1 with: - labels: ${{ steps.packageLabel.outputs.label }} + labels: ${{ steps.additionalLabel.outputs.label }} From f1b8291a898bba76d5226bd7912824c00f08beb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Apr 2025 21:23:44 +0000 Subject: [PATCH 06/19] build(deps): Bump astro from 4.16.1 to 4.16.18 in /dev-packages/e2e-tests/test-applications/cloudflare-astro (#16087) --- .../e2e-tests/test-applications/cloudflare-astro/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json index d2fc66736b4f..5adbcd6ad75f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json @@ -19,7 +19,7 @@ "dependencies": { "@astrojs/cloudflare": "8.1.0", "@sentry/astro": "latest || *", - "astro": "4.16.1" + "astro": "4.16.18" }, "devDependencies": { "@astrojs/internal-helpers": "0.4.1" From 87e5f8b563149329a9004442149d62dc027ee5c6 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:35:03 +0200 Subject: [PATCH 07/19] test(nuxt): Add tests for trace baggage (#16046) Check whether the baggage data is propagated correctly in connected traces. --- .../tests/tracing.test.ts | 20 ++++++++++++++++++- .../nuxt-3-min/tests/tracing.test.ts | 20 ++++++++++++++++++- .../tests/tracing.test.ts | 20 ++++++++++++++++++- .../nuxt-3/tests/tracing.test.ts | 20 ++++++++++++++++++- .../nuxt-4/tests/tracing.test.ts | 20 ++++++++++++++++++- 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts index fc14335e0bd9..0bc6ffa80b73 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/tracing.test.ts @@ -20,6 +20,18 @@ test.describe('distributed tracing', () => { expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + expect(clientTxnEvent).toMatchObject({ transaction: '/test-param/:param()', transaction_info: { source: 'route' }, @@ -28,12 +40,14 @@ test.describe('distributed tracing', () => { trace: { op: 'pageload', origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, }, }, }); expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -45,7 +59,11 @@ test.describe('distributed tracing', () => { }); // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts index b110f27843e2..cb86df11fe84 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/tracing.test.ts @@ -20,6 +20,18 @@ test.describe('distributed tracing', () => { expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + expect(clientTxnEvent).toMatchObject({ transaction: '/test-param/:param()', transaction_info: { source: 'route' }, @@ -28,12 +40,14 @@ test.describe('distributed tracing', () => { trace: { op: 'pageload', origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, }, }, }); expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -45,7 +59,11 @@ test.describe('distributed tracing', () => { }); // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts index e8df55587799..69c4bd2833c4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/tracing.test.ts @@ -20,6 +20,18 @@ test.describe('distributed tracing', () => { expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + expect(clientTxnEvent).toMatchObject({ transaction: '/test-param/:param()', transaction_info: { source: 'route' }, @@ -28,12 +40,14 @@ test.describe('distributed tracing', () => { trace: { op: 'pageload', origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, }, }, }); expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -45,7 +59,11 @@ test.describe('distributed tracing', () => { }); // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts index 46e2b135a9b7..523ece4cc085 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/tracing.test.ts @@ -20,6 +20,18 @@ test.describe('distributed tracing', () => { expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + expect(clientTxnEvent).toMatchObject({ transaction: '/test-param/:param()', transaction_info: { source: 'route' }, @@ -28,12 +40,14 @@ test.describe('distributed tracing', () => { trace: { op: 'pageload', origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, }, }, }); expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -45,7 +59,11 @@ test.describe('distributed tracing', () => { }); // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts index 7f5240674110..505a912c95d5 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/tracing.test.ts @@ -20,6 +20,18 @@ test.describe('distributed tracing', () => { expect(page.getByText(`Param: ${PARAM}`)).toBeVisible(), ]); + const baggageMetaTagContent = await page.locator('meta[name="baggage"]').getAttribute('content'); + + expect(baggageMetaTagContent).toContain(`sentry-trace_id=${serverTxnEvent.contexts?.trace?.trace_id}`); + expect(baggageMetaTagContent).toContain(`sentry-transaction=GET%20%2Ftest-param%2F${PARAM}`); // URL-encoded for 'GET /test-param/s0me-param' + expect(baggageMetaTagContent).toContain('sentry-sampled=true'); + expect(baggageMetaTagContent).toContain('sentry-sample_rate=1'); + + const sentryTraceMetaTagContent = await page.locator('meta[name="sentry-trace"]').getAttribute('content'); + const [metaTraceId, metaParentSpanId, metaSampled] = sentryTraceMetaTagContent?.split('-') || []; + + expect(metaSampled).toBe('1'); + expect(clientTxnEvent).toMatchObject({ transaction: '/test-param/:param()', transaction_info: { source: 'route' }, @@ -28,12 +40,14 @@ test.describe('distributed tracing', () => { trace: { op: 'pageload', origin: 'auto.pageload.vue', + trace_id: metaTraceId, + parent_span_id: metaParentSpanId, }, }, }); expect(serverTxnEvent).toMatchObject({ - transaction: 'GET /test-param/s0me-param', // todo: parametrize (nitro) + transaction: `GET /test-param/${PARAM}`, // todo: parametrize (nitro) transaction_info: { source: 'url' }, type: 'transaction', contexts: { @@ -45,7 +59,11 @@ test.describe('distributed tracing', () => { }); // connected trace + expect(clientTxnEvent.contexts?.trace?.trace_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBeDefined(); + expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverTxnEvent.contexts?.trace?.span_id); + expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); }); From 263eeee309dd994d283764f950de57384d8227cd Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 17 Apr 2025 14:35:37 +0100 Subject: [PATCH 08/19] feat: Add Supabase Integration (#15719) Ref: https://github.com/getsentry/sentry-javascript/issues/15436 Summary: - Ports https://github.com/supabase-community/sentry-integration-js into `@sentry/core` - Adds support for `auth` and `auth.admin` operations - Adds browser integration tests - Adds E2E tests running on NextJS --------- Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Co-authored-by: Luca Forstner --- .../browser-integration-tests/package.json | 1 + .../suites/integrations/supabase/auth/init.js | 24 + .../suites/integrations/supabase/auth/test.ts | 147 +++++ .../supabase/db-operations/init.js | 28 + .../supabase/db-operations/test.ts | 90 +++ .../supabase-nextjs/.gitignore | 39 ++ .../test-applications/supabase-nextjs/.npmrc | 2 + .../supabase-nextjs/components/TodoList.tsx | 125 +++++ .../supabase-nextjs/instrumentation.ts | 13 + .../supabase-nextjs/lib/initSupabaseAdmin.ts | 21 + .../supabase-nextjs/lib/initSupabaseAnon.ts | 15 + .../supabase-nextjs/lib/schema.ts | 49 ++ .../supabase-nextjs/next.config.js | 51 ++ .../supabase-nextjs/package.json | 40 ++ .../supabase-nextjs/pages/_app.tsx | 13 + .../supabase-nextjs/pages/_document.tsx | 13 + .../supabase-nextjs/pages/_error.jsx | 17 + .../pages/api/add-todo-entry.ts | 47 ++ .../pages/api/create-test-user.ts | 29 + .../supabase-nextjs/pages/api/list-users.ts | 22 + .../supabase-nextjs/pages/index.tsx | 39 ++ .../supabase-nextjs/playwright.config.mjs | 8 + .../supabase-nextjs/sentry.client.config.ts | 31 ++ .../supabase-nextjs/sentry.edge.config.ts | 17 + .../supabase-nextjs/sentry.server.config.ts | 17 + .../supabase-nextjs/start-event-proxy.mjs | 6 + .../supabase-nextjs/supabase/.gitignore | 13 + .../supabase-nextjs/supabase/config.toml | 307 +++++++++++ .../migrations/20230712094349_init.sql | 16 + .../supabase-nextjs/supabase/seed.sql | 2 + .../supabase-nextjs/tests/performance.test.ts | 177 ++++++ .../supabase-nextjs/tsconfig.json | 24 + packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/browser/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/cloudflare/src/index.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/integrations/supabase.ts | 515 ++++++++++++++++++ packages/deno/src/index.ts | 2 + packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/index.ts | 2 + packages/remix/src/cloudflare/index.ts | 2 + packages/remix/src/server/index.ts | 2 + packages/solidstart/src/server/index.ts | 2 + packages/sveltekit/src/server/index.ts | 2 + packages/sveltekit/src/worker/index.ts | 2 + packages/vercel-edge/src/index.ts | 2 + yarn.lock | 64 ++- 49 files changed, 2050 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/auth/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/test.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/components/TodoList.tsx create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAdmin.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAnon.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/schema.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/next.config.js create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_app.tsx create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_document.tsx create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_error.jsx create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/add-todo-entry.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/create-test-user.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/list-users.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/supabase-nextjs/tsconfig.json create mode 100644 packages/core/src/integrations/supabase.ts diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 644b801d6d34..fb0666338998 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -43,6 +43,7 @@ "@playwright/test": "~1.50.0", "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "9.13.0", + "@supabase/supabase-js": "2.49.3", "axios": "1.8.2", "babel-loader": "^8.2.2", "fflate": "0.8.2", diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/init.js new file mode 100644 index 000000000000..0d76a283878e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/init.js @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/browser'; + +import { createClient } from '@supabase/supabase-js'; +window.Sentry = Sentry; + +const supabaseClient = createClient('https://test.supabase.co', 'test-key'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })], + tracesSampleRate: 1.0, +}); + +// Simulate authentication operations +async function performAuthenticationOperations() { + await supabaseClient.auth.signInWithPassword({ + email: 'test@example.com', + password: 'test-password', + }); + + await supabaseClient.auth.signOut(); +} + +performAuthenticationOperations(); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts new file mode 100644 index 000000000000..31277f4afe3c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/auth/test.ts @@ -0,0 +1,147 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +async function mockSupabaseAuthRoutesSuccess(page: Page) { + await page.route('**/auth/v1/token?grant_type=password**', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + token_type: 'bearer', + expires_in: 3600, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + await page.route('**/auth/v1/logout**', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + message: 'Logged out', + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); +} + +async function mockSupabaseAuthRoutesFailure(page: Page) { + await page.route('**/auth/v1/token?grant_type=password**', route => { + return route.fulfill({ + status: 400, + body: JSON.stringify({ + error_description: 'Invalid email or password', + error: 'invalid_grant', + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + await page.route('**/auth/v1/logout**', route => { + return route.fulfill({ + status: 400, + body: JSON.stringify({ + error_description: 'Invalid refresh token', + error: 'invalid_grant', + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); +} + + +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + +sentryTest('should capture Supabase authentication spans', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + await mockSupabaseAuthRoutesSuccess(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + const supabaseSpans = eventData.spans?.filter(({ op }) => op?.startsWith('db.auth')); + + expect(supabaseSpans).toHaveLength(2); + expect(supabaseSpans![0]).toMatchObject({ + description: 'signInWithPassword', + parent_span_id: eventData.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: eventData.contexts?.trace?.trace_id, + status: 'ok', + data: expect.objectContaining({ + 'sentry.op': 'db.auth.signInWithPassword', + 'sentry.origin': 'auto.db.supabase', + }), + }); + + expect(supabaseSpans![1]).toMatchObject({ + description: 'signOut', + parent_span_id: eventData.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: eventData.contexts?.trace?.trace_id, + status: 'ok', + data: expect.objectContaining({ + 'sentry.op': 'db.auth.signOut', + 'sentry.origin': 'auto.db.supabase', + }), + }); +}); + +sentryTest('should capture Supabase authentication errors', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + await mockSupabaseAuthRoutesFailure(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const [errorEvent, transactionEvent] = await getMultipleSentryEnvelopeRequests(page, 2, { url }); + + const supabaseSpans = transactionEvent.spans?.filter(({ op }) => op?.startsWith('db.auth')); + + expect(errorEvent.exception?.values?.[0].value).toBe('Invalid email or password'); + + expect(supabaseSpans).toHaveLength(2); + expect(supabaseSpans![0]).toMatchObject({ + description: 'signInWithPassword', + parent_span_id: transactionEvent.contexts?.trace?.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: transactionEvent.contexts?.trace?.trace_id, + status: 'unknown_error', + data: expect.objectContaining({ + 'sentry.op': 'db.auth.signInWithPassword', + 'sentry.origin': 'auto.db.supabase', + }), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js new file mode 100644 index 000000000000..fbb60cd104c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/init.js @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/browser'; + +import { createClient } from '@supabase/supabase-js'; +window.Sentry = Sentry; + +const supabaseClient = createClient('https://test.supabase.co', 'test-key'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.supabaseIntegration({ supabaseClient })], + tracesSampleRate: 1.0, +}); + +// Simulate database operations +async function performDatabaseOperations() { + try { + await supabaseClient.from('todos').insert([{ title: 'Test Todo' }]); + + await supabaseClient.from('todos').select('*'); + + // Trigger an error to capture the breadcrumbs + throw new Error('Test Error'); + } catch (error) { + Sentry.captureException(error); + } +} + +performDatabaseOperations(); diff --git a/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/test.ts b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/test.ts new file mode 100644 index 000000000000..cb9fe0430228 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/supabase/db-operations/test.ts @@ -0,0 +1,90 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +async function mockSupabaseRoute(page: Page) { + await page.route('**/rest/v1/todos**', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + userNames: ['John', 'Jane'], + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); +} + + +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + + +sentryTest('should capture Supabase database operation breadcrumbs', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + await mockSupabaseRoute(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.breadcrumbs).toBeDefined(); + expect(eventData.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.insert', + message: 'from(todos)', + data: expect.any(Object), + }); +}); + +sentryTest('should capture multiple Supabase operations in sequence', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + await mockSupabaseRoute(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const events = await getMultipleSentryEnvelopeRequests(page, 2, { url }); + + expect(events).toHaveLength(2); + + events.forEach(event => { + expect( + event.breadcrumbs?.some(breadcrumb => breadcrumb.type === 'supabase' && breadcrumb?.category?.startsWith('db.')), + ).toBe(true); + }); +}); + +sentryTest('should include correct data payload in Supabase breadcrumbs', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + return; + } + + await mockSupabaseRoute(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + const supabaseBreadcrumb = eventData.breadcrumbs?.find(b => b.type === 'supabase'); + + expect(supabaseBreadcrumb).toBeDefined(); + expect(supabaseBreadcrumb?.data).toMatchObject({ + query: expect.arrayContaining([ + 'filter(columns, )' + ]), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/.gitignore b/dev-packages/e2e-tests/test-applications/supabase-nextjs/.gitignore new file mode 100644 index 000000000000..e7e8ec25eed1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/.npmrc b/dev-packages/e2e-tests/test-applications/supabase-nextjs/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/components/TodoList.tsx b/dev-packages/e2e-tests/test-applications/supabase-nextjs/components/TodoList.tsx new file mode 100644 index 000000000000..6fe5b810e05b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/components/TodoList.tsx @@ -0,0 +1,125 @@ +import { Database } from '@/lib/schema'; +import { Session, useSupabaseClient } from '@supabase/auth-helpers-react'; +import { useEffect, useState } from 'react'; + +type Todos = Database['public']['Tables']['todos']['Row']; + +export default function TodoList({ session }: { session: Session }) { + const supabase = useSupabaseClient(); + const [todos, setTodos] = useState([]); + const [newTaskText, setNewTaskText] = useState(''); + const [errorText, setErrorText] = useState(''); + + const user = session.user; + + useEffect(() => { + const fetchTodos = async () => { + const { data: todos, error } = await supabase.from('todos').select('*').order('id', { ascending: true }); + + if (error) console.log('error', error); + else setTodos(todos); + }; + + fetchTodos(); + }, [supabase]); + + const addTodo = async (taskText: string) => { + let task = taskText.trim(); + if (task.length) { + const { data: todo, error } = await supabase.from('todos').insert({ task, user_id: user.id }).select().single(); + + if (error) { + setErrorText(error.message); + } else { + setTodos([...todos, todo]); + setNewTaskText(''); + } + } + }; + + const deleteTodo = async (id: number) => { + try { + await supabase.from('todos').delete().eq('id', id).throwOnError(); + setTodos(todos.filter(x => x.id != id)); + } catch (error) { + console.log('error', error); + } + }; + + return ( +
+

Todo List.

+
{ + e.preventDefault(); + addTodo(newTaskText); + }} + > + { + setErrorText(''); + setNewTaskText(e.target.value); + }} + /> + +
+ {!!errorText && } +
    + {todos.map(todo => ( + deleteTodo(todo.id)} /> + ))} +
+
+ ); +} + +const Todo = ({ todo, onDelete }: { todo: Todos; onDelete: () => void }) => { + const supabase = useSupabaseClient(); + const [isCompleted, setIsCompleted] = useState(todo.is_complete); + + const toggle = async () => { + try { + const { data } = await supabase + .from('todos') + .update({ is_complete: !isCompleted }) + .eq('id', todo.id) + .throwOnError() + .select() + .single(); + + if (data) setIsCompleted(data.is_complete); + } catch (error) { + console.log('error', error); + } + }; + + return ( +
  • +
    +
    +
    {todo.task}
    +
    +
    + toggle()} + type="checkbox" + checked={isCompleted ? true : false} + /> +
    + +
    +
  • + ); +}; + +const Alert = ({ text }: { text: string }) =>
    {text}
    ; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/instrumentation.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAdmin.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAdmin.ts new file mode 100644 index 000000000000..d48b315cdd08 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAdmin.ts @@ -0,0 +1,21 @@ +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const SUPABASE_SERVICE_ROLE_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'; + +export const getSupabaseClient = () => { + const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); + + Sentry.instrumentSupabaseClient(supabaseClient); + + return supabaseClient; +}; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAnon.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAnon.ts new file mode 100644 index 000000000000..4e8ab6acc2b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/initSupabaseAnon.ts @@ -0,0 +1,15 @@ +import { createClient } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/nextjs'; + +// These are the default development keys for a local Supabase instance +const NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'; +const NEXT_PUBLIC_SUPABASE_ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4cGxvcmV0ZXN0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2'; + +export const getSupabaseClient = () => { + const supabaseClient = createClient(NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY); + + Sentry.instrumentSupabaseClient(supabaseClient); + + return supabaseClient; +}; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/schema.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/schema.ts new file mode 100644 index 000000000000..ec8b8f854b2a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/lib/schema.ts @@ -0,0 +1,49 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json } + | Json[] + +export interface Database { + public: { + Tables: { + todos: { + Row: { + id: number + inserted_at: string + is_complete: boolean | null + task: string | null + user_id: string + } + Insert: { + id?: number + inserted_at?: string + is_complete?: boolean | null + task?: string | null + user_id: string + } + Update: { + id?: number + inserted_at?: string + is_complete?: boolean | null + task?: string | null + user_id?: string + } + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/next.config.js b/dev-packages/e2e-tests/test-applications/supabase-nextjs/next.config.js new file mode 100644 index 000000000000..003a6cb03964 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/next.config.js @@ -0,0 +1,51 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +} + +module.exports = nextConfig + + +// Injected content via Sentry wizard below + +const { withSentryConfig } = require("@sentry/nextjs"); + +module.exports = withSentryConfig( + module.exports, + { + // For all available options, see: + // https://www.npmjs.com/package/@sentry/webpack-plugin#options + + org: "sentry-sdks", + project: "sentry-javascript-nextjs", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Automatically annotate React components to show their full name in breadcrumbs and session replay + reactComponentAnnotation: { + enabled: true, + }, + + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: false, + + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, + } +); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json new file mode 100644 index 000000000000..a46519e9c75d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/package.json @@ -0,0 +1,40 @@ +{ + "name": "supabase-nextjs-e2e-test-app", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "concurrently \"next dev\"", + "build": "next build", + "start": "next start", + "clean": "npx rimraf node_modules pnpm-lock.yaml .next", + "start-local-supabase": "supabase init --force --workdir . && supabase start -o env && supabase db reset", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm start-local-supabase && pnpm build", + "test:assert": "pnpm test:prod" + }, + "dependencies": { + "@next/font": "14.2.15", + "@sentry/nextjs": "latest || *", + "@supabase/auth-helpers-react": "0.5.0", + "@supabase/auth-ui-react": "0.4.7", + "@supabase/supabase-js": "2.49.1", + "@types/node": "18.14.0", + "@types/react": "18.0.28", + "@types/react-dom": "18.0.11", + "concurrently": "7.6.0", + "next": "14.2.25", + "react": "18.2.0", + "react-dom": "18.2.0", + "supabase": "2.19.7", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "eslint": "8.34.0", + "eslint-config-next": "14.2.25" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_app.tsx b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_app.tsx new file mode 100644 index 000000000000..b3d470023b6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_app.tsx @@ -0,0 +1,13 @@ +import { getSupabaseClient } from '@/lib/initSupabaseAnon' +import { SessionContextProvider } from '@supabase/auth-helpers-react' +import type { AppProps } from 'next/app' + +const supabaseClient = getSupabaseClient() + +export default function App({ Component, pageProps }: AppProps) { + return ( + + + + ) +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_document.tsx b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_document.tsx new file mode 100644 index 000000000000..54e8bf3e2a29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_document.tsx @@ -0,0 +1,13 @@ +import { Html, Head, Main, NextScript } from 'next/document' + +export default function Document() { + return ( + + + +
    + + + + ) +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_error.jsx b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_error.jsx new file mode 100644 index 000000000000..46a61d690c38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/_error.jsx @@ -0,0 +1,17 @@ +import * as Sentry from "@sentry/nextjs"; +import Error from "next/error"; + +const CustomErrorComponent = (props) => { + return ; +}; + +CustomErrorComponent.getInitialProps = async (contextData) => { + // In case this is running in a serverless function, await this in order to give Sentry + // time to send the error before the lambda exits + await Sentry.captureUnderscoreErrorException(contextData); + + // This will contain the status code of the response + return Error.getInitialProps(contextData); +}; + +export default CustomErrorComponent; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/add-todo-entry.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/add-todo-entry.ts new file mode 100644 index 000000000000..e75cac13fc4c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/add-todo-entry.ts @@ -0,0 +1,47 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSupabaseClient } from '@/lib/initSupabaseAdmin'; + +type Data = { + data: any; + error: any; +}; + +const supabaseClient = getSupabaseClient(); + +async function login() { + const { data, error } = await supabaseClient.auth.signInWithPassword({ + email: 'test@sentry.test', + password: 'sentry.test', + }); + + if (error) { + console.log('error', error); + } + + return data; +} + +async function addTodoEntry(userId?: string) { + const { error } = await supabaseClient.from('todos').insert({ task: 'test', user_id: userId }).select().single(); + + if (error) { + console.log('error', error); + } +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { user } = await login(); + + await addTodoEntry(user?.id); + + const { data, error } = await supabaseClient.from('todos').select('*'); + + if (error) { + console.log('error', error); + } + + res.status(200).json({ + data, + error, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/create-test-user.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/create-test-user.ts new file mode 100644 index 000000000000..57b0c210afa8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/create-test-user.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSupabaseClient } from '@/lib/initSupabaseAdmin'; + +type Data = { + data: any; + error: any; +}; + +const supabaseClient = getSupabaseClient(); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Note for test usage + // This only works once in tests as it will error if the user already exists + // So this should be called only once before all tests to create the user + const { data, error } = await supabaseClient.auth.admin.createUser({ + email: 'test@sentry.test', + password: 'sentry.test', + email_confirm: true, + }); + + if (error) { + console.warn('ERROR', error); + } + + res.status(200).json({ + data, + error, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/list-users.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/list-users.ts new file mode 100644 index 000000000000..fdbfbc8328a1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/api/list-users.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSupabaseClient } from '@/lib/initSupabaseAdmin'; + +type Data = { + data: any; + error: any; +}; + +const supabaseClient = getSupabaseClient(); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { data, error } = await supabaseClient.auth.admin.listUsers(); + + if (error) { + console.warn('ERROR', error); + } + + res.status(200).json({ + data, + error, + }); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/index.tsx b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/index.tsx new file mode 100644 index 000000000000..e3b04bb22534 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/pages/index.tsx @@ -0,0 +1,39 @@ +import Head from 'next/head'; +import { useSession, useSupabaseClient } from '@supabase/auth-helpers-react'; +import { Auth } from '@supabase/auth-ui-react'; +import TodoList from '@/components/TodoList'; + +export default function Home() { + const session = useSession(); + const supabase = useSupabaseClient(); + + return ( + <> + + Create Next App + + + +
    + {!session ? ( +
    + Login + +
    + ) : ( +
    + + +
    + )} +
    + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/supabase-nextjs/playwright.config.mjs new file mode 100644 index 000000000000..a35fe82a4001 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm dev`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.client.config.ts new file mode 100644 index 000000000000..acd2f0768675 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.client.config.ts @@ -0,0 +1,31 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'qa', // dynamic sampling bias to keep transactions + + // Add optional integrations for additional features + integrations: [ + Sentry.replayIntegration(), + ], + tunnel: 'http://localhost:3031/', // proxy server + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Define how likely Replay events are sampled. + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // Define how likely Replay events are sampled when an error occurs. + replaysOnErrorSampleRate: 1.0, + transportOptions: { + // We expect the app to send a lot of events in a short time + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.edge.config.ts new file mode 100644 index 000000000000..59ad9eb6befe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.edge.config.ts @@ -0,0 +1,17 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'qa', // dynamic sampling bias to keep transactions + sendDefaultPii: true, + tracesSampleRate: 1, + tunnel: 'http://localhost:3031/', // proxy server + transportOptions: { + // We expect the app to send a lot of events in a short time + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.server.config.ts new file mode 100644 index 000000000000..a9966e3a71a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/sentry.server.config.ts @@ -0,0 +1,17 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1, + sendDefaultPii: true, + tunnel: 'http://localhost:3031/', // proxy server + transportOptions: { + // We expect the app to send a lot of events in a short time + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/supabase-nextjs/start-event-proxy.mjs new file mode 100644 index 000000000000..2f41cb42d4ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'supabase-nextjs', +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/.gitignore b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/.gitignore new file mode 100644 index 000000000000..a735017e0d2a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/.gitignore @@ -0,0 +1,13 @@ +# Supabase +.branches +.temp +.env + +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml new file mode 100644 index 000000000000..35dcff35bec4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/config.toml @@ -0,0 +1,307 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "supabase-nextjs" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 + + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql new file mode 100644 index 000000000000..1b1a98ace2e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/migrations/20230712094349_init.sql @@ -0,0 +1,16 @@ +create table todos ( + id bigint generated by default as identity primary key, + user_id uuid references auth.users not null, + task text check (char_length(task) > 3), + is_complete boolean default false, + inserted_at timestamp with time zone default timezone('utc'::text, now()) not null +); +alter table todos enable row level security; +create policy "Individuals can create todos." on todos for + insert with check (auth.uid() = user_id); +create policy "Individuals can view their own todos. " on todos for + select using (auth.uid() = user_id); +create policy "Individuals can update their own todos." on todos for + update using (auth.uid() = user_id); +create policy "Individuals can delete their own todos." on todos for + delete using (auth.uid() = user_id); \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql new file mode 100644 index 000000000000..57b5c4d07e05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/supabase/seed.sql @@ -0,0 +1,2 @@ +TRUNCATE auth.users CASCADE; +TRUNCATE auth.identities CASCADE; diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts new file mode 100644 index 000000000000..80eb1a166e9b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tests/performance.test.ts @@ -0,0 +1,177 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// This test should be run in serial mode to ensure that the test user is created before the other tests +test.describe.configure({ mode: 'serial' }); + +// This should be the first test as it will be needed for the other tests +test('Sends server-side Supabase auth admin `createUser` span', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/create-test-user' + ); + }); + + await fetch(`${baseURL}/api/create-test-user`); + const transactionEvent = await httpTransactionPromise; + + expect(transactionEvent.spans).toContainEqual({ + data: expect.any(Object), + description: 'createUser', + op: 'db.auth.admin.createUser', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.db.supabase', + }); +}); + +test('Sends client-side Supabase db-operation spans and breadcrumbs to Sentry', async ({ page, baseURL }) => { + const pageloadTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + // Fill in login credentials + // The email and password should be the same as the ones used in the `create-test-user` endpoint + await page.locator('input[name=email]').fill('test@sentry.test'); + await page.locator('input[name=password]').fill('sentry.test'); + await page.locator('button[type=submit]').click(); + + // Wait for login to complete + await page.waitForSelector('button:has-text("Add")'); + + // Add a new todo entry + await page.locator('input[id=new-task-text]').fill('test'); + await page.locator('button[id=add-task]').click(); + + const transactionEvent = await pageloadTransactionPromise; + + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + description: 'from(todos)', + op: 'db.select', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.db.supabase', + }), + ); + + expect(transactionEvent.spans).toContainEqual({ + data: expect.any(Object), + description: 'from(todos)', + op: 'db.insert', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.db.supabase', + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.select', + message: 'from(todos)', + data: expect.any(Object), + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.insert', + message: 'from(todos)', + data: expect.any(Object), + }); +}); + +test('Sends server-side Supabase db-operation spans and breadcrumbs to Sentry', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/add-todo-entry' + ); + }); + + await fetch(`${baseURL}/api/add-todo-entry`); + const transactionEvent = await httpTransactionPromise; + + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + description: 'from(todos)', + op: 'db.select', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.db.supabase', + }), + ); + + expect(transactionEvent.spans).toContainEqual({ + data: expect.any(Object), + description: 'from(todos)', + op: 'db.insert', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.db.supabase', + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.select', + message: 'from(todos)', + data: expect.any(Object), + }); + + expect(transactionEvent.breadcrumbs).toContainEqual({ + timestamp: expect.any(Number), + type: 'supabase', + category: 'db.insert', + message: 'from(todos)', + data: expect.any(Object), + }); +}); + +test('Sends server-side Supabase auth admin `listUsers` span', async ({ page, baseURL }) => { + const httpTransactionPromise = waitForTransaction('supabase-nextjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/list-users' + ); + }); + + await fetch(`${baseURL}/api/list-users`); + const transactionEvent = await httpTransactionPromise; + + expect(transactionEvent.spans).toContainEqual({ + data: expect.any(Object), + description: 'listUsers', + op: 'db.auth.admin.listUsers', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.db.supabase', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/supabase-nextjs/tsconfig.json b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tsconfig.json new file mode 100644 index 000000000000..f4ab65fd2ebf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/supabase-nextjs/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 78bf958ce243..a14b10df4b8d 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -126,6 +126,8 @@ export { withIsolationScope, withMonitor, withScope, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, profiler, logger, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 7dd6bcb597ca..cf72f7290975 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -110,6 +110,8 @@ export { spanToBaggageHeader, trpcMiddleware, updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 275144cd280c..dd079cfc0241 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -59,6 +59,8 @@ export { setHttpStatus, makeMultiplexedTransport, moduleMetadataIntegration, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, thirdPartyErrorFilterIntegration, } from '@sentry/core'; diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c8d11b4d101d..a8d9a7016b0e 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -128,6 +128,8 @@ export { spanToBaggageHeader, trpcMiddleware, updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index faad474cc801..551929eb405a 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -75,6 +75,8 @@ export { rewriteFramesIntegration, captureConsoleIntegration, moduleMetadataIntegration, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 71a8b03acacb..b5dac82ffa54 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -106,6 +106,7 @@ export { captureConsoleIntegration } from './integrations/captureconsole'; export { dedupeIntegration } from './integrations/dedupe'; export { extraErrorDataIntegration } from './integrations/extraerrordata'; export { rewriteFramesIntegration } from './integrations/rewriteframes'; +export { supabaseIntegration, instrumentSupabaseClient } from './integrations/supabase'; export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { consoleIntegration } from './integrations/console'; diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts new file mode 100644 index 000000000000..f6541da51aa2 --- /dev/null +++ b/packages/core/src/integrations/supabase.ts @@ -0,0 +1,515 @@ +// Based on Kamil Ogórek's work on: +// https://github.com/supabase-community/sentry-integration-js + +/* eslint-disable max-lines */ +import { logger, isPlainObject } from '../utils-hoist'; + +import type { IntegrationFn } from '../types-hoist'; +import { setHttpStatus, startSpan } from '../tracing'; +import { addBreadcrumb } from '../breadcrumbs'; +import { defineIntegration } from '../integration'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; +import { captureException } from '../exports'; +import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '../tracing'; +import { DEBUG_BUILD } from '../debug-build'; + +const AUTH_OPERATIONS_TO_INSTRUMENT = [ + 'reauthenticate', + 'signInAnonymously', + 'signInWithOAuth', + 'signInWithIdToken', + 'signInWithOtp', + 'signInWithPassword', + 'signInWithSSO', + 'signOut', + 'signUp', + 'verifyOtp', +]; + +const AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT = [ + 'createUser', + 'deleteUser', + 'listUsers', + 'getUserById', + 'updateUserById', + 'inviteUserByEmail', +]; + +export const FILTER_MAPPINGS = { + eq: 'eq', + neq: 'neq', + gt: 'gt', + gte: 'gte', + lt: 'lt', + lte: 'lte', + like: 'like', + 'like(all)': 'likeAllOf', + 'like(any)': 'likeAnyOf', + ilike: 'ilike', + 'ilike(all)': 'ilikeAllOf', + 'ilike(any)': 'ilikeAnyOf', + is: 'is', + in: 'in', + cs: 'contains', + cd: 'containedBy', + sr: 'rangeGt', + nxl: 'rangeGte', + sl: 'rangeLt', + nxr: 'rangeLte', + adj: 'rangeAdjacent', + ov: 'overlaps', + fts: '', + plfts: 'plain', + phfts: 'phrase', + wfts: 'websearch', + not: 'not', +}; + +export const DB_OPERATIONS_TO_INSTRUMENT = ['select', 'insert', 'upsert', 'update', 'delete']; + +type AuthOperationFn = (...args: unknown[]) => Promise; +type AuthOperationName = (typeof AUTH_OPERATIONS_TO_INSTRUMENT)[number]; +type AuthAdminOperationName = (typeof AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT)[number]; +type PostgRESTQueryOperationFn = (...args: unknown[]) => PostgRESTFilterBuilder; + +export interface SupabaseClientInstance { + auth: { + admin: Record; + } & Record; +} + +export interface PostgRESTQueryBuilder { + [key: string]: PostgRESTQueryOperationFn; +} + +export interface PostgRESTFilterBuilder { + method: string; + headers: Record; + url: URL; + schema: string; + body: any; +} + +export interface SupabaseResponse { + status?: number; + error?: { + message: string; + code?: string; + details?: unknown; + }; +} + +export interface SupabaseError extends Error { + code?: string; + details?: unknown; +} + +export interface SupabaseBreadcrumb { + type: string; + category: string; + message: string; + data?: { + query?: string[]; + body?: Record; + }; +} + +export interface SupabaseClientConstructor { + prototype: { + from: (table: string) => PostgRESTQueryBuilder; + }; +} + +export interface PostgRESTProtoThenable { + then: ( + onfulfilled?: ((value: T) => T | PromiseLike) | null, + onrejected?: ((reason: any) => T | PromiseLike) | null, + ) => Promise; +} + +type SentryInstrumented = T & { + __SENTRY_INSTRUMENTED__?: boolean; +}; + +function markAsInstrumented(fn: T): void { + try { + (fn as SentryInstrumented).__SENTRY_INSTRUMENTED__ = true; + } catch { + // ignore errors here + } +} + +function isInstrumented(fn: T): boolean | undefined { + try { + return (fn as SentryInstrumented).__SENTRY_INSTRUMENTED__; + } catch { + return false; + } +} + +/** + * Extracts the database operation type from the HTTP method and headers + * @param method - The HTTP method of the request + * @param headers - The request headers + * @returns The database operation type ('select', 'insert', 'upsert', 'update', or 'delete') + */ +export function extractOperation(method: string, headers: Record = {}): string { + switch (method) { + case 'GET': { + return 'select'; + } + case 'POST': { + if (headers['Prefer']?.includes('resolution=')) { + return 'upsert'; + } else { + return 'insert'; + } + } + case 'PATCH': { + return 'update'; + } + case 'DELETE': { + return 'delete'; + } + default: { + return ''; + } + } +} + +/** + * Translates Supabase filter parameters into readable method names for tracing + * @param key - The filter key from the URL search parameters + * @param query - The filter value from the URL search parameters + * @returns A string representation of the filter as a method call + */ +export function translateFiltersIntoMethods(key: string, query: string): string { + if (query === '' || query === '*') { + return 'select(*)'; + } + + if (key === 'select') { + return `select(${query})`; + } + + if (key === 'or' || key.endsWith('.or')) { + return `${key}${query}`; + } + + const [filter, ...value] = query.split('.'); + + let method; + // Handle optional `configPart` of the filter + if (filter?.startsWith('fts')) { + method = 'textSearch'; + } else if (filter?.startsWith('plfts')) { + method = 'textSearch[plain]'; + } else if (filter?.startsWith('phfts')) { + method = 'textSearch[phrase]'; + } else if (filter?.startsWith('wfts')) { + method = 'textSearch[websearch]'; + } else { + method = (filter && FILTER_MAPPINGS[filter as keyof typeof FILTER_MAPPINGS]) || 'filter'; + } + + return `${method}(${key}, ${value.join('.')})`; +} + +function instrumentAuthOperation(operation: AuthOperationFn, isAdmin = false): AuthOperationFn { + return new Proxy(operation, { + apply(target, thisArg, argumentsList) { + return startSpan( + { + name: operation.name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `db.auth.${isAdmin ? 'admin.' : ''}${operation.name}`, + }, + }, + span => { + return Reflect.apply(target, thisArg, argumentsList) + .then((res: unknown) => { + if (res && typeof res === 'object' && 'error' in res && res.error) { + span.setStatus({ code: SPAN_STATUS_ERROR }); + + captureException(res.error, { + mechanism: { + handled: false, + }, + }); + } else { + span.setStatus({ code: SPAN_STATUS_OK }); + } + + span.end(); + return res; + }) + .catch((err: unknown) => { + span.setStatus({ code: SPAN_STATUS_ERROR }); + span.end(); + + captureException(err, { + mechanism: { + handled: false, + }, + }); + + throw err; + }) + .then(...argumentsList); + }, + ); + }, + }); +} + +function instrumentSupabaseAuthClient(supabaseClientInstance: SupabaseClientInstance): void { + const auth = supabaseClientInstance.auth; + + if (!auth || isInstrumented(supabaseClientInstance.auth)) { + return; + } + + for (const operation of AUTH_OPERATIONS_TO_INSTRUMENT) { + const authOperation = auth[operation]; + + if (!authOperation) { + continue; + } + + if (typeof supabaseClientInstance.auth[operation] === 'function') { + supabaseClientInstance.auth[operation] = instrumentAuthOperation(authOperation); + } + } + + for (const operation of AUTH_ADMIN_OPERATIONS_TO_INSTRUMENT) { + const authOperation = auth.admin[operation]; + + if (!authOperation) { + continue; + } + + if (typeof supabaseClientInstance.auth.admin[operation] === 'function') { + supabaseClientInstance.auth.admin[operation] = instrumentAuthOperation(authOperation, true); + } + } + + markAsInstrumented(supabaseClientInstance.auth); +} + +function instrumentSupabaseClientConstructor(SupabaseClient: unknown): void { + if (isInstrumented((SupabaseClient as unknown as SupabaseClientConstructor).prototype.from)) { + return; + } + + (SupabaseClient as unknown as SupabaseClientConstructor).prototype.from = new Proxy( + (SupabaseClient as unknown as SupabaseClientConstructor).prototype.from, + { + apply(target, thisArg, argumentsList) { + const rv = Reflect.apply(target, thisArg, argumentsList); + const PostgRESTQueryBuilder = (rv as PostgRESTQueryBuilder).constructor; + + instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder as unknown as new () => PostgRESTQueryBuilder); + + return rv; + }, + }, + ); + + markAsInstrumented((SupabaseClient as unknown as SupabaseClientConstructor).prototype.from); +} + +function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilterBuilder['constructor']): void { + if (isInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then)) { + return; + } + + (PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then = new Proxy( + (PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then, + { + apply(target, thisArg, argumentsList) { + const operations = DB_OPERATIONS_TO_INSTRUMENT; + const typedThis = thisArg as PostgRESTFilterBuilder; + const operation = extractOperation(typedThis.method, typedThis.headers); + + if (!operations.includes(operation)) { + return Reflect.apply(target, thisArg, argumentsList); + } + + if (!typedThis?.url?.pathname || typeof typedThis.url.pathname !== 'string') { + return Reflect.apply(target, thisArg, argumentsList); + } + + const pathParts = typedThis.url.pathname.split('/'); + const table = pathParts.length > 0 ? pathParts[pathParts.length - 1] : ''; + const description = `from(${table})`; + + const queryItems: string[] = []; + for (const [key, value] of typedThis.url.searchParams.entries()) { + // It's possible to have multiple entries for the same key, eg. `id=eq.7&id=eq.3`, + // so we need to use array instead of object to collect them. + queryItems.push(translateFiltersIntoMethods(key, value)); + } + + const body: Record = Object.create(null); + if (isPlainObject(typedThis.body)) { + for (const [key, value] of Object.entries(typedThis.body)) { + body[key] = value; + } + } + + const attributes: Record = { + 'db.table': table, + 'db.schema': typedThis.schema, + 'db.url': typedThis.url.origin, + 'db.sdk': typedThis.headers['X-Client-Info'], + 'db.system': 'postgresql', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `db.${operation}`, + }; + + if (queryItems.length) { + attributes['db.query'] = queryItems; + } + + if (Object.keys(body).length) { + attributes['db.body'] = body; + } + + return startSpan( + { + name: description, + attributes, + }, + span => { + return (Reflect.apply(target, thisArg, []) as Promise) + .then( + (res: SupabaseResponse) => { + if (span) { + if (res && typeof res === 'object' && 'status' in res) { + setHttpStatus(span, res.status || 500); + } + span.end(); + } + + if (res.error) { + const err = new Error(res.error.message) as SupabaseError; + if (res.error.code) { + err.code = res.error.code; + } + if (res.error.details) { + err.details = res.error.details; + } + + const supabaseContext: Record = {}; + if (queryItems.length) { + supabaseContext.query = queryItems; + } + if (Object.keys(body).length) { + supabaseContext.body = body; + } + + captureException(err, { + contexts: { + supabase: supabaseContext, + }, + }); + } + + const breadcrumb: SupabaseBreadcrumb = { + type: 'supabase', + category: `db.${operation}`, + message: description, + }; + + const data: Record = {}; + + if (queryItems.length) { + data.query = queryItems; + } + + if (Object.keys(body).length) { + data.body = body; + } + + if (Object.keys(data).length) { + breadcrumb.data = data; + } + + addBreadcrumb(breadcrumb); + + return res; + }, + (err: Error) => { + if (span) { + setHttpStatus(span, 500); + span.end(); + } + throw err; + }, + ) + .then(...argumentsList); + }, + ); + }, + }, + ); + + markAsInstrumented((PostgRESTFilterBuilder.prototype as unknown as PostgRESTProtoThenable).then); +} + +function instrumentPostgRESTQueryBuilder(PostgRESTQueryBuilder: new () => PostgRESTQueryBuilder): void { + // We need to wrap _all_ operations despite them sharing the same `PostgRESTFilterBuilder` + // constructor, as we don't know which method will be called first, and we don't want to miss any calls. + for (const operation of DB_OPERATIONS_TO_INSTRUMENT) { + if (isInstrumented((PostgRESTQueryBuilder.prototype as Record)[operation])) { + continue; + } + + type PostgRESTOperation = keyof Pick; + (PostgRESTQueryBuilder.prototype as Record)[operation as PostgRESTOperation] = new Proxy( + (PostgRESTQueryBuilder.prototype as Record)[operation as PostgRESTOperation], + { + apply(target, thisArg, argumentsList) { + const rv = Reflect.apply(target, thisArg, argumentsList); + const PostgRESTFilterBuilder = (rv as PostgRESTFilterBuilder).constructor; + + DEBUG_BUILD && logger.log(`Instrumenting ${operation} operation's PostgRESTFilterBuilder`); + + instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder); + + return rv; + }, + }, + ); + + markAsInstrumented((PostgRESTQueryBuilder.prototype as Record)[operation]); + } +} + +export const instrumentSupabaseClient = (supabaseClient: unknown): void => { + if (!supabaseClient) { + DEBUG_BUILD && logger.warn('Supabase integration was not installed because no Supabase client was provided.'); + return; + } + const SupabaseClientConstructor = + supabaseClient.constructor === Function ? supabaseClient : supabaseClient.constructor; + + instrumentSupabaseClientConstructor(SupabaseClientConstructor); + instrumentSupabaseAuthClient(supabaseClient as SupabaseClientInstance); +}; + +const INTEGRATION_NAME = 'Supabase'; + +const _supabaseIntegration = ((supabaseClient: unknown) => { + return { + setupOnce() { + instrumentSupabaseClient(supabaseClient); + }, + name: INTEGRATION_NAME, + }; +}) satisfies IntegrationFn; + +export const supabaseIntegration = defineIntegration((options: { supabaseClient: any }) => { + return _supabaseIntegration(options.supabaseClient); +}) satisfies IntegrationFn; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index a906197b40c2..dde05fca3ea4 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -71,6 +71,8 @@ export { dedupeIntegration, extraErrorDataIntegration, rewriteFramesIntegration, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 5e6b81e9c68b..1bb5d0984a32 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -108,6 +108,8 @@ export { spanToBaggageHeader, trpcMiddleware, updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, profiler, amqplibIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8d999343a1ae..0f0c39f82fc9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -128,6 +128,8 @@ export { spanToBaggageHeader, trpcMiddleware, updateSpanName, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, profiler, consoleLoggingIntegration, diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 5d15be8edee7..958376f802a3 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -95,6 +95,8 @@ export { rewriteFramesIntegration, captureConsoleIntegration, moduleMetadataIntegration, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 69daf708dd31..f90ff55eca6c 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -111,6 +111,8 @@ export { withIsolationScope, withMonitor, withScope, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, logger, consoleLoggingIntegration, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 1753b6252517..06e97a20a96a 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -114,6 +114,8 @@ export { withIsolationScope, withMonitor, withScope, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, logger, consoleLoggingIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index ce2c3c476b56..5e49fa45fed3 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -116,6 +116,8 @@ export { withIsolationScope, withMonitor, withScope, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, logger, consoleLoggingIntegration, diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index 8e0e549440ca..9fc8429e5864 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -78,6 +78,8 @@ export { withIsolationScope, withMonitor, withScope, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, } from '@sentry/cloudflare'; diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 64ae281481d1..98a83d042928 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -75,6 +75,8 @@ export { rewriteFramesIntegration, captureConsoleIntegration, moduleMetadataIntegration, + supabaseIntegration, + instrumentSupabaseClient, zodErrorsIntegration, consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, diff --git a/yarn.lock b/yarn.lock index b9636e97c211..8895daad18dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7226,6 +7226,63 @@ dependencies: "@testing-library/dom" "^9.3.1" +"@supabase/auth-js@2.69.1": + version "2.69.1" + resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.69.1.tgz#fcf310d24dfab823ffbf22191e6ceaef933360d8" + integrity sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/functions-js@2.4.4": + version "2.4.4" + resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.4.tgz#45fcd94d546bdfa66d01f93a796ca0304ec154b8" + integrity sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/node-fetch@2.6.15", "@supabase/node-fetch@^2.6.14": + version "2.6.15" + resolved "https://registry.yarnpkg.com/@supabase/node-fetch/-/node-fetch-2.6.15.tgz#731271430e276983191930816303c44159e7226c" + integrity sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ== + dependencies: + whatwg-url "^5.0.0" + +"@supabase/postgrest-js@1.19.2": + version "1.19.2" + resolved "https://registry.yarnpkg.com/@supabase/postgrest-js/-/postgrest-js-1.19.2.tgz#cb721860fefd9ec2818bbafc56de4314c0ebca81" + integrity sha512-MXRbk4wpwhWl9IN6rIY1mR8uZCCG4MZAEji942ve6nMwIqnBgBnZhZlON6zTTs6fgveMnoCILpZv1+K91jN+ow== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/realtime-js@2.11.2": + version "2.11.2" + resolved "https://registry.yarnpkg.com/@supabase/realtime-js/-/realtime-js-2.11.2.tgz#7f7399c326be717eadc9d5e259f9e2690fbf83dd" + integrity sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w== + dependencies: + "@supabase/node-fetch" "^2.6.14" + "@types/phoenix" "^1.5.4" + "@types/ws" "^8.5.10" + ws "^8.18.0" + +"@supabase/storage-js@2.7.1": + version "2.7.1" + resolved "https://registry.yarnpkg.com/@supabase/storage-js/-/storage-js-2.7.1.tgz#761482f237deec98a59e5af1ace18c7a5e0a69af" + integrity sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/supabase-js@2.49.3": + version "2.49.3" + resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.49.3.tgz#789b01074b9e62ea6e41657ad65b3c06610ea3c5" + integrity sha512-42imTuAm9VEQGlXT0O6zrSwNnsIblU1eieqrAWj8HSmFaYkxepk/IuUVw1M5hKelk0ZYlqDKNwRErI1rF1EL4w== + dependencies: + "@supabase/auth-js" "2.69.1" + "@supabase/functions-js" "2.4.4" + "@supabase/node-fetch" "2.6.15" + "@supabase/postgrest-js" "1.19.2" + "@supabase/realtime-js" "2.11.2" + "@supabase/storage-js" "2.7.1" + "@sveltejs/kit@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-2.0.2.tgz#bd02523fe570ddaf89148bffb1eb2233c458054b" @@ -8057,6 +8114,11 @@ pg-protocol "*" pg-types "^2.2.0" +"@types/phoenix@^1.5.4": + version "1.6.6" + resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.6.6.tgz#3c1ab53fd5a23634b8e37ea72ccacbf07fbc7816" + integrity sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A== + "@types/prop-types@*": version "15.7.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" @@ -8266,7 +8328,7 @@ dependencies: "@types/webidl-conversions" "*" -"@types/ws@*", "@types/ws@^8.5.1": +"@types/ws@*", "@types/ws@^8.5.1", "@types/ws@^8.5.10": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== From 5cd34578b78c9289d89e5fc49c7db3bf781c4052 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 17 Apr 2025 17:23:15 +0200 Subject: [PATCH 09/19] feat(nuxt): Log when adding HTML trace meta tags (#16044) Logging the tracing meta tags makes debugging [things like this](https://github.com/getsentry/sentry-javascript/issues/16039) easier. --- packages/nuxt/src/runtime/utils.ts | 2 ++ packages/nuxt/test/runtime/plugins/server.test.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index c6eb59807764..fb247504f78a 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,4 +1,5 @@ import type { ClientOptions, Context } from '@sentry/core'; +import { logger } from '@sentry/core'; import { captureException, getClient, getTraceMetaTags } from '@sentry/core'; import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack'; @@ -37,6 +38,7 @@ export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head']): v const metaTags = getTraceMetaTags(); if (metaTags) { + logger.log('Adding Sentry tracing meta tags to HTML page:', metaTags); head.push(metaTags); } } diff --git a/packages/nuxt/test/runtime/plugins/server.test.ts b/packages/nuxt/test/runtime/plugins/server.test.ts index 5750f0f9495f..2190e4ed5ef3 100644 --- a/packages/nuxt/test/runtime/plugins/server.test.ts +++ b/packages/nuxt/test/runtime/plugins/server.test.ts @@ -2,9 +2,13 @@ import { getTraceMetaTags } from '@sentry/core'; import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; import { addSentryTracingMetaTags } from '../../../src/runtime/utils'; -vi.mock('@sentry/core', () => ({ - getTraceMetaTags: vi.fn(), -})); +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + return { + ...mod, + getTraceMetaTags: vi.fn(), + }; +}); describe('addSentryTracingMetaTags', () => { afterEach(() => { From e4f459796d9ed8e1d3219feff1dd137548fa26e3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 22 Apr 2025 09:48:21 +0200 Subject: [PATCH 10/19] feat(react-router): Trace propagation (#16070) - Adds new util function `getMetaTagTransformer` for injecting trace meta tags to the html `` - Adds helper function `createSentryHandleRequest` which is a complete Sentry-instrumented handleRequest implementation that handles both route parametrization and trace meta tag injection. -> this is for users that do not need any modifications in their `handleRequest` function - Renames `sentryHandleRequest` to `wrapSentryHandleRequest` to avoid confusion closes https://github.com/getsentry/sentry-javascript/issues/15515 --- .../app/entry.server.tsx | 67 +--- .../performance/trace-propagation.test.ts | 36 ++ .../src/server/createSentryHandleRequest.tsx | 138 +++++++ packages/react-router/src/server/index.ts | 4 +- ...eRequest.ts => wrapSentryHandleRequest.ts} | 33 +- .../server/createSentryHandleRequest.test.ts | 340 ++++++++++++++++++ .../server/wrapSentryHandleRequest.test.ts | 183 ++++++++++ packages/react-router/tsconfig.json | 3 +- 8 files changed, 742 insertions(+), 62 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts create mode 100644 packages/react-router/src/server/createSentryHandleRequest.tsx rename packages/react-router/src/server/{sentryHandleRequest.ts => wrapSentryHandleRequest.ts} (61%) create mode 100644 packages/react-router/test/server/createSentryHandleRequest.test.ts create mode 100644 packages/react-router/test/server/wrapSentryHandleRequest.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx index 567edfe4e032..97260755da21 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx @@ -1,68 +1,19 @@ -import { PassThrough } from 'node:stream'; - import { createReadableStreamFromReadable } from '@react-router/node'; import * as Sentry from '@sentry/react-router'; -import { isbot } from 'isbot'; -import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; -import type { AppLoadContext, EntryContext } from 'react-router'; import { ServerRouter } from 'react-router'; -const ABORT_DELAY = 5_000; - -function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - routerContext: EntryContext, - loadContext: AppLoadContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - let userAgent = request.headers.get('user-agent'); - - // Ensure requests from bots and SPA Mode renders wait for all content to load before responding - // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation - let readyOption: keyof RenderToPipeableStreamOptions = - (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady'; - - const { pipe, abort } = renderToPipeableStream(, { - [readyOption]() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - }); +import { type HandleErrorFunction } from 'react-router'; - setTimeout(abort, ABORT_DELAY); - }); -} +const ABORT_DELAY = 5_000; -export default Sentry.sentryHandleRequest(handleRequest); +const handleRequest = Sentry.createSentryHandleRequest({ + streamTimeout: ABORT_DELAY, + ServerRouter, + renderToPipeableStream, + createReadableStreamFromReadable, +}); -import { type HandleErrorFunction } from 'react-router'; +export default handleRequest; export const handleError: HandleErrorFunction = (error, { request }) => { // React Router may abort some interrupted requests, don't log those diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts new file mode 100644 index 000000000000..6a9623171236 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto(`/`); + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-transaction='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET *'; + }); + + const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/'; + }); + + await page.goto(`/`); + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id); + }); +}); diff --git a/packages/react-router/src/server/createSentryHandleRequest.tsx b/packages/react-router/src/server/createSentryHandleRequest.tsx new file mode 100644 index 000000000000..662d0b14a93a --- /dev/null +++ b/packages/react-router/src/server/createSentryHandleRequest.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router'; +import type { ReactNode } from 'react'; +import { getMetaTagTransformer, wrapSentryHandleRequest } from './wrapSentryHandleRequest'; +import type { createReadableStreamFromReadable } from '@react-router/node'; +import { PassThrough } from 'stream'; + +type RenderToPipeableStreamOptions = { + [key: string]: unknown; + onShellReady?: () => void; + onAllReady?: () => void; + onShellError?: (error: unknown) => void; + onError?: (error: unknown) => void; +}; + +type RenderToPipeableStreamResult = { + pipe: (destination: NodeJS.WritableStream) => void; + abort: () => void; +}; + +type RenderToPipeableStreamFunction = ( + node: ReactNode, + options: RenderToPipeableStreamOptions, +) => RenderToPipeableStreamResult; + +export interface SentryHandleRequestOptions { + /** + * Timeout in milliseconds after which the rendering stream will be aborted + * @default 10000 + */ + streamTimeout?: number; + + /** + * React's renderToPipeableStream function from 'react-dom/server' + */ + renderToPipeableStream: RenderToPipeableStreamFunction; + + /** + * The component from '@react-router/server' + */ + ServerRouter: typeof ServerRouter; + + /** + * createReadableStreamFromReadable from '@react-router/node' + */ + createReadableStreamFromReadable: typeof createReadableStreamFromReadable; + + /** + * Regular expression to identify bot user agents + * @default /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i + */ + botRegex?: RegExp; +} + +/** + * A complete Sentry-instrumented handleRequest implementation that handles both + * route parametrization and trace meta tag injection. + * + * @param options Configuration options + * @returns A Sentry-instrumented handleRequest function + */ +export function createSentryHandleRequest( + options: SentryHandleRequestOptions, +): ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, +) => Promise { + const { + streamTimeout = 10000, + renderToPipeableStream, + ServerRouter, + createReadableStreamFromReadable, + botRegex = /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i, + } = options; + + const handleRequest = function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + _loadContext: AppLoadContext, + ): Promise { + return new Promise((resolve, reject) => { + let shellRendered = false; + const userAgent = request.headers.get('user-agent'); + + // Determine if we should use onAllReady or onShellReady + const isBot = typeof userAgent === 'string' && botRegex.test(userAgent); + const isSpaMode = !!(routerContext as { isSpaMode?: boolean }).isSpaMode; + + const readyOption = isBot || isSpaMode ? 'onAllReady' : 'onShellReady'; + + const { pipe, abort } = renderToPipeableStream(, { + [readyOption]() { + shellRendered = true; + const body = new PassThrough(); + + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + // this injects trace data to the HTML head + pipe(getMetaTagTransformer(body)); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + // eslint-disable-next-line no-param-reassign + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + // eslint-disable-next-line no-console + console.error(error); + } + }, + }); + + // Abort the rendering stream after the `streamTimeout` + setTimeout(abort, streamTimeout); + }); + }; + + // Wrap the handle request function for request parametrization + return wrapSentryHandleRequest(handleRequest); +} diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 44acfec7d4f2..67436582aedd 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -1,4 +1,6 @@ export * from '@sentry/node'; export { init } from './sdk'; -export { sentryHandleRequest } from './sentryHandleRequest'; +// eslint-disable-next-line deprecation/deprecation +export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } from './wrapSentryHandleRequest'; +export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; diff --git a/packages/react-router/src/server/sentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts similarity index 61% rename from packages/react-router/src/server/sentryHandleRequest.ts rename to packages/react-router/src/server/wrapSentryHandleRequest.ts index 9c5f4abf72e8..bc6cc93122bb 100644 --- a/packages/react-router/src/server/sentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -1,8 +1,10 @@ import { context } from '@opentelemetry/api'; import { RPCType, getRPCMetadata } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan, getTraceMetaTags } from '@sentry/core'; import type { AppLoadContext, EntryContext } from 'react-router'; +import type { PassThrough } from 'stream'; +import { Transform } from 'stream'; type OriginalHandleRequest = ( request: Request, @@ -18,7 +20,7 @@ type OriginalHandleRequest = ( * @param originalHandle - The original handleRequest function to wrap * @returns A wrapped version of the handle request function with Sentry instrumentation */ -export function sentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest { +export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest { return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, @@ -47,6 +49,33 @@ export function sentryHandleRequest(originalHandle: OriginalHandleRequest): Orig }); } } + return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); }; } + +/** @deprecated Use `wrapSentryHandleRequest` instead. */ +export const sentryHandleRequest = wrapSentryHandleRequest; + +/** + * Injects Sentry trace meta tags into the HTML response by piping through a transform stream. + * This enables distributed tracing by adding trace context to the HTML document head. + * + * @param body - PassThrough stream containing the HTML response body to modify + */ +export function getMetaTagTransformer(body: PassThrough): Transform { + const headClosingTag = ''; + const htmlMetaTagTransformer = new Transform({ + transform(chunk, _encoding, callback) { + const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk); + if (html.includes(headClosingTag)) { + const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`); + callback(null, modifiedHtml); + return; + } + callback(null, chunk); + }, + }); + htmlMetaTagTransformer.pipe(body); + return htmlMetaTagTransformer; +} diff --git a/packages/react-router/test/server/createSentryHandleRequest.test.ts b/packages/react-router/test/server/createSentryHandleRequest.test.ts new file mode 100644 index 000000000000..0db84d19ce16 --- /dev/null +++ b/packages/react-router/test/server/createSentryHandleRequest.test.ts @@ -0,0 +1,340 @@ +/* eslint-disable no-console */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PassThrough } from 'stream'; +import * as wrapSentryHandleRequestModule from '../../src/server/wrapSentryHandleRequest'; +import { createSentryHandleRequest } from '../../src/server/createSentryHandleRequest'; +import type { EntryContext } from 'react-router'; + +vi.mock('../../src/server/wrapSentryHandleRequest', () => ({ + wrapSentryHandleRequest: vi.fn(fn => fn), + getMetaTagTransformer: vi.fn(body => { + const transform = new PassThrough(); + transform.pipe(body); + return transform; + }), +})); + +describe('createSentryHandleRequest', () => { + const mockRenderToPipeableStream = vi.fn(); + const mockServerRouter = vi.fn(); + const mockCreateReadableStreamFromReadable = vi.fn(); + + const mockRequest = { + url: 'https://sentry-example.com/test', + headers: { + get: vi.fn(), + }, + } as unknown as Request; + + let mockResponseHeaders: Headers; + + const mockRouterContext: EntryContext = { + manifest: { + entry: { + imports: [], + module: 'test-module', + }, + routes: {}, + url: '/test', + version: '1.0.0', + }, + routeModules: {}, + future: {}, + isSpaMode: false, + staticHandlerContext: { + matches: [ + { + route: { + path: 'test', + id: 'test-route', + }, + params: {}, + pathname: '/test', + pathnameBase: '/test', + }, + ], + loaderData: {}, + actionData: null, + errors: null, + basename: '/', + location: { + pathname: '/test', + search: '', + hash: '', + state: null, + key: 'default', + }, + statusCode: 200, + loaderHeaders: {}, + actionHeaders: {}, + }, + }; + + const mockLoadContext = {}; + + const mockPipe = vi.fn(); + const mockAbort = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + mockResponseHeaders = new Headers(); + vi.spyOn(mockResponseHeaders, 'set'); + + mockRenderToPipeableStream.mockReturnValue({ + pipe: mockPipe, + abort: mockAbort, + }); + + mockCreateReadableStreamFromReadable.mockImplementation(body => body); + + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should create a handleRequest function', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + expect(handleRequest).toBeDefined(); + expect(typeof handleRequest).toBe('function'); + expect(wrapSentryHandleRequestModule.wrapSentryHandleRequest).toHaveBeenCalled(); + }); + + it('should use the default stream timeout if not provided', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + vi.advanceTimersByTime(10000); + expect(mockAbort).toHaveBeenCalled(); + }); + + it('should use a custom stream timeout if provided', () => { + const customTimeout = 5000; + + const handleRequest = createSentryHandleRequest({ + streamTimeout: customTimeout, + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + vi.advanceTimersByTime(customTimeout - 1); + expect(mockAbort).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(mockAbort).toHaveBeenCalled(); + }); + + it('should use the default bot regex if not provided', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Googlebot/2.1'); + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onAllReady: expect.any(Function), + }), + ); + }); + + it('should use a custom bot regex if provided', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + botRegex: /custom-bot/i, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Googlebot/2.1'); + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onShellReady: expect.any(Function), + }), + ); + + vi.clearAllMocks(); + (mockRequest.headers.get as ReturnType).mockReturnValue('custom-bot/1.0'); + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onAllReady: expect.any(Function), + }), + ); + }); + + it('should use onAllReady for SPA mode', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Mozilla/5.0'); + const spaRouterContext = { ...mockRouterContext, isSpaMode: true }; + + handleRequest(mockRequest, 200, mockResponseHeaders, spaRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onAllReady: expect.any(Function), + }), + ); + }); + + it('should use onShellReady for regular browsers', () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + (mockRequest.headers.get as ReturnType).mockReturnValue('Mozilla/5.0'); + + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockRenderToPipeableStream).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + onShellReady: expect.any(Function), + }), + ); + }); + + it('should set Content-Type header when shell is ready', async () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + if (options.onShellReady) { + options.onShellReady(); + } + return { pipe: mockPipe, abort: mockAbort }; + }); + + await handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + expect(mockResponseHeaders.set).toHaveBeenCalledWith('Content-Type', 'text/html'); + }); + + it('should pipe to the meta tag transformer', async () => { + const getMetaTagTransformerSpy = vi.spyOn(wrapSentryHandleRequestModule, 'getMetaTagTransformer'); + + const pipeSpy = vi.fn(); + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + // Call the ready callback synchronously to trigger the code path we want to test + setTimeout(() => { + if (options.onShellReady) { + options.onShellReady(); + } + }, 0); + + return { + pipe: pipeSpy, + abort: mockAbort, + }; + }); + + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + const promise = handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + // Advance timers to trigger the setTimeout in our mock + await vi.runAllTimersAsync(); + await promise; + + expect(getMetaTagTransformerSpy).toHaveBeenCalled(); + expect(getMetaTagTransformerSpy.mock.calls[0]?.[0]).toBeInstanceOf(PassThrough); + expect(pipeSpy).toHaveBeenCalled(); + }); + + it('should set status code to 500 on error after shell is rendered', async () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + const originalConsoleError = console.error; + console.error = vi.fn(); + + let shellReadyCallback: (() => void) | undefined; + let errorCallback: ((error: Error) => void) | undefined; + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + shellReadyCallback = options.onShellReady; + errorCallback = options.onError; + return { pipe: mockPipe, abort: mockAbort }; + }); + + const responsePromise = handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext); + + // First trigger shellReady to set shellRendered = true + // Then trigger onError to cause the error handling + if (shellReadyCallback) { + shellReadyCallback(); + } + + if (errorCallback) { + errorCallback(new Error('Test error')); + } + + await responsePromise; + expect(console.error).toHaveBeenCalled(); + console.error = originalConsoleError; + }); + + it('should reject the promise on shell error', async () => { + const handleRequest = createSentryHandleRequest({ + renderToPipeableStream: mockRenderToPipeableStream, + ServerRouter: mockServerRouter, + createReadableStreamFromReadable: mockCreateReadableStreamFromReadable, + }); + + const testError = new Error('Shell error'); + + mockRenderToPipeableStream.mockImplementation((jsx, options) => { + if (options.onShellError) { + options.onShellError(testError); + } + return { pipe: mockPipe, abort: mockAbort }; + }); + + await expect( + handleRequest(mockRequest, 200, mockResponseHeaders, mockRouterContext, mockLoadContext), + ).rejects.toThrow(testError); + }); +}); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts new file mode 100644 index 000000000000..e29e97f14c57 --- /dev/null +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -0,0 +1,183 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { RPCType } from '@opentelemetry/core'; +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan, getTraceMetaTags } from '@sentry/core'; +import { PassThrough } from 'stream'; +import { wrapSentryHandleRequest, getMetaTagTransformer } from '../../src/server/wrapSentryHandleRequest'; + +vi.mock('@opentelemetry/core', () => ({ + RPCType: { HTTP: 'http' }, + getRPCMetadata: vi.fn(), +})); + +vi.mock('@sentry/core', () => ({ + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + getTraceMetaTags: vi.fn(), +})); + +describe('wrapSentryHandleRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should call original handler with same parameters', async () => { + const originalHandler = vi.fn().mockResolvedValue('original response'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://taco.burrito'); + const responseStatusCode = 200; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + const result = await wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext); + + expect(originalHandler).toHaveBeenCalledWith( + request, + responseStatusCode, + responseHeaders, + routerContext, + loadContext, + ); + expect(result).toBe('original response'); + }); + + test('should set span attributes when parameterized path exists and active span exists', async () => { + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const mockActiveSpan = { setAttribute: vi.fn() }; + const mockRootSpan = { setAttributes: vi.fn() }; + const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); + vi.mocked(vi.importActual('@opentelemetry/core')).getRPCMetadata = getRPCMetadata; + + const routerContext = { + staticHandlerContext: { + matches: [{ route: { path: 'some-path' } }], + }, + } as any; + + await wrappedHandler(new Request('https://nacho.queso'), 200, new Headers(), routerContext, {} as any); + + expect(getActiveSpan).toHaveBeenCalled(); + expect(getRootSpan).toHaveBeenCalledWith(mockActiveSpan); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [ATTR_HTTP_ROUTE]: '/some-path', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }); + expect(mockRpcMetadata.route).toBe('/some-path'); + }); + + test('should not set span attributes when parameterized path does not exist', async () => { + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + } as any; + + await wrappedHandler(new Request('https://guapo.chulo'), 200, new Headers(), routerContext, {} as any); + + expect(getActiveSpan).not.toHaveBeenCalled(); + }); + + test('should not set span attributes when active span does not exist', async () => { + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + (getActiveSpan as unknown as ReturnType).mockReturnValue(null); + + const routerContext = { + staticHandlerContext: { + matches: [{ route: { path: 'some-path' } }], + }, + } as any; + + await wrappedHandler(new Request('https://tio.pepe'), 200, new Headers(), routerContext, {} as any); + + expect(getActiveSpan).toHaveBeenCalled(); + expect(getRootSpan).not.toHaveBeenCalled(); + }); +}); + +describe('getMetaTagTransformer', () => { + beforeEach(() => { + vi.clearAllMocks(); + (getTraceMetaTags as unknown as ReturnType).mockReturnValue( + '', + ); + }); + + test('should inject meta tags before closing head tag', done => { + const outputStream = new PassThrough(); + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + outputStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + outputStream.on('end', () => { + expect(outputData).toContain(''); + expect(outputData).not.toContain(''); + done(); + }); + + transformer.pipe(outputStream); + + bodyStream.write('Test'); + bodyStream.end(); + }); + + test('should not modify chunks without head closing tag', done => { + const outputStream = new PassThrough(); + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + outputStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + outputStream.on('end', () => { + expect(outputData).toBe('Test'); + expect(getTraceMetaTags).toHaveBeenCalled(); + done(); + }); + + transformer.pipe(outputStream); + + bodyStream.write('Test'); + bodyStream.end(); + }); + + test('should handle buffer input', done => { + const outputStream = new PassThrough(); + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + outputStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + outputStream.on('end', () => { + expect(outputData).toContain(''); + done(); + }); + + transformer.pipe(outputStream); + + bodyStream.write(Buffer.from('Test')); + bodyStream.end(); + }); +}); diff --git a/packages/react-router/tsconfig.json b/packages/react-router/tsconfig.json index 5f80a125a0dc..aa2dc034c7c3 100644 --- a/packages/react-router/tsconfig.json +++ b/packages/react-router/tsconfig.json @@ -4,6 +4,7 @@ "include": ["src/**/*"], "compilerOptions": { - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "jsx": "react" } } From 1d10238b4e6fce45de7f2871cd5da4389e3e898f Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 22 Apr 2025 11:15:48 +0200 Subject: [PATCH 11/19] ref(node): Log when incoming request bodies are being captured (#16104) We used to swallow most things when we try to capture request bodies of incoming requests in node. This can make it hard to debug this. This PR adds some log messages here: 1. Logs when we successfully patched `request.on` 2. Logs the errors that happen during patching ref https://github.com/getsentry/sentry-javascript/issues/16090 --- .../http/SentryHttpInstrumentation.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 0e0502b6fd1f..32b97e628d66 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -30,6 +30,8 @@ import { getRequestInfo } from './vendor/getRequestInfo'; type Http = typeof http; type Https = typeof https; +const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; + export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * Whether breadcrumbs should be recorded for requests. @@ -101,7 +103,7 @@ const MAX_BODY_BYTE_LENGTH = 1024 * 1024; */ export class SentryHttpInstrumentation extends InstrumentationBase { public constructor(config: SentryHttpInstrumentationOptions = {}) { - super('@sentry/instrumentation-http', VERSION, config); + super(INSTRUMENTATION_NAME, VERSION, config); } /** @inheritdoc */ @@ -377,6 +379,10 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): apply: (target, thisArg, args: Parameters) => { const [event, listener, ...restArgs] = args; + if (DEBUG_BUILD) { + logger.log(INSTRUMENTATION_NAME, 'Patching request.on', event); + } + if (event === 'data') { const callback = new Proxy(listener, { apply: (target, thisArg, args: Parameters) => { @@ -387,7 +393,8 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): chunks.push(chunk); } else if (DEBUG_BUILD) { logger.log( - `Dropping request body chunk because it maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`, + INSTRUMENTATION_NAME, + `Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`, ); } @@ -410,8 +417,10 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): const normalizedRequest = { data: body } satisfies RequestEventData; isolationScope.setSDKProcessingMetadata({ normalizedRequest }); } - } catch { - // ignore errors here + } catch (error) { + if (DEBUG_BUILD) { + logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); + } } return Reflect.apply(target, thisArg, args); @@ -445,8 +454,10 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): return Reflect.apply(target, thisArg, args); }, }); - } catch { - // ignore errors if we can't patch stuff + } catch (error) { + if (DEBUG_BUILD) { + logger.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); + } } } From 8026b854a1925b581381fadd038e241a6d094d4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:49:58 +0000 Subject: [PATCH 12/19] build(deps): Bump fastify from 5.0.0 to 5.3.2 in /dev-packages/e2e-tests/test-applications/node-fastify-5 (#16097) --- .../e2e-tests/test-applications/node-fastify-5/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json index f9f4f726eb0e..08d6245f771e 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json @@ -15,7 +15,7 @@ "@sentry/core": "latest || *", "@sentry/opentelemetry": "latest || *", "@types/node": "^18.19.1", - "fastify": "5.0.0", + "fastify": "5.3.2", "typescript": "5.6.3", "ts-node": "10.9.2" }, From 447e62e89dc576995f7ebe791d3a133f7561c708 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:50:29 +0000 Subject: [PATCH 13/19] feat(deps): Bump @prisma/instrumentation from 6.5.0 to 6.6.0 (#16102) --- packages/node/package.json | 2 +- yarn.lock | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/node/package.json b/packages/node/package.json index 1d14d411238a..cfce53271da4 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -95,7 +95,7 @@ "@opentelemetry/resources": "^1.30.1", "@opentelemetry/sdk-trace-base": "^1.30.1", "@opentelemetry/semantic-conventions": "^1.30.0", - "@prisma/instrumentation": "6.5.0", + "@prisma/instrumentation": "6.6.0", "@sentry/core": "9.13.0", "@sentry/opentelemetry": "9.13.0", "import-in-the-middle": "^1.13.0" diff --git a/yarn.lock b/yarn.lock index 8895daad18dc..1dc296fdb9c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5860,10 +5860,10 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73" integrity sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw== -"@prisma/instrumentation@6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.5.0.tgz#ce6c160365dfccbe0f4e7c57a4afc4f946fee562" - integrity sha512-morJDtFRoAp5d/KENEm+K6Y3PQcn5bCvpJ5a9y3V3DNMrNy/ZSn2zulPGj+ld+Xj2UYVoaMJ8DpBX/o6iF6OiA== +"@prisma/instrumentation@6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.6.0.tgz#5b73164c722bcfcd29c43cb883b4735143b65eb2" + integrity sha512-M/a6njz3hbf2oucwdbjNKrSMLuyMCwgDrmTtkF1pm4Nm7CU45J/Hd6lauF2CDACTUYzu3ymcV7P0ZAhIoj6WRw== dependencies: "@opentelemetry/instrumentation" "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" @@ -27080,6 +27080,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 88ba26b6ff6174d4b5719899da109bcd9f23dac6 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 23 Apr 2025 11:20:35 +0200 Subject: [PATCH 14/19] fix(node): Make body capturing more robust (#16105) I have a hunch that not all frameworks call `req.on('end')` but may only do `req.on('close')` or whatever else and this is why we are not reliably capturing bodies. It should be safe to attach a `req.on('end')` handler, as that doesn't change any semantics AFAIK. Fixes https://github.com/getsentry/sentry-javascript/issues/16090 --------- Co-authored-by: Francesco Gringl-Novy --- .../test-applications/node-fastify/src/app.ts | 4 ++ .../node-fastify/tests/transactions.test.ts | 37 +++++++++++ .../http/SentryHttpInstrumentation.ts | 66 ++++++++----------- 3 files changed, 69 insertions(+), 38 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-fastify/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify/src/app.ts index 0f37bc33b90a..7f7ac390b4b3 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify/src/app.ts @@ -119,6 +119,10 @@ app.get('/test-outgoing-http-external-disallowed', async function (req, res) { res.send(data); }); +app.post('/test-post', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + app.listen({ port: port }); // A second app so we can test header propagation between external URLs diff --git a/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts index 01e07538dc72..f7c0aa7f5b0e 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify/tests/transactions.test.ts @@ -124,3 +124,40 @@ test('Sends an API route transaction', async ({ baseURL }) => { origin: 'manual', }); }); + +test('Captures request metadata', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-fastify', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'POST /test-post' + ); + }); + + const res = await fetch(`${baseURL}/test-post`, { + method: 'POST', + body: JSON.stringify({ foo: 'bar', other: 1 }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const resBody = await res.json(); + + expect(resBody).toEqual({ status: 'ok', body: { foo: 'bar', other: 1 } }); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.request).toEqual({ + cookies: {}, + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: expect.objectContaining({ + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/json', + }), + data: JSON.stringify({ + foo: 'bar', + other: 1, + }), + }); + + expect(transactionEvent.user).toEqual(undefined); +}); diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 32b97e628d66..d9ef31fa579b 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -3,7 +3,7 @@ import { context, propagation } from '@opentelemetry/api'; import { VERSION } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import type { AggregationCounts, Client, RequestEventData, SanitizedRequestData, Scope } from '@sentry/core'; +import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core'; import { addBreadcrumb, generateSpanId, @@ -360,12 +360,9 @@ function getBreadcrumbData(request: http.ClientRequest): Partial acc + chunk.byteLength, 0); - } - /** * We need to keep track of the original callbacks, in order to be able to remove listeners again. * Since `off` depends on having the exact same function reference passed in, we need to be able to map @@ -386,41 +383,21 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): if (event === 'data') { const callback = new Proxy(listener, { apply: (target, thisArg, args: Parameters) => { - // If we have already read more than the max body length, we stop adding chunks - // To avoid growing the memory indefinitely if a response is e.g. streamed - if (getChunksSize() < MAX_BODY_BYTE_LENGTH) { - const chunk = args[0] as Buffer; - chunks.push(chunk); - } else if (DEBUG_BUILD) { - logger.log( - INSTRUMENTATION_NAME, - `Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`, - ); - } - - return Reflect.apply(target, thisArg, args); - }, - }); - - callbackMap.set(listener, callback); - - return Reflect.apply(target, thisArg, [event, callback, ...restArgs]); - } - - if (event === 'end') { - const callback = new Proxy(listener, { - apply: (target, thisArg, args) => { try { - const body = Buffer.concat(chunks).toString('utf-8'); - - if (body) { - const normalizedRequest = { data: body } satisfies RequestEventData; - isolationScope.setSDKProcessingMetadata({ normalizedRequest }); - } - } catch (error) { - if (DEBUG_BUILD) { - logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); + const chunk = args[0] as Buffer | string; + const bufferifiedChunk = Buffer.from(chunk); + + if (bodyByteLength < MAX_BODY_BYTE_LENGTH) { + chunks.push(bufferifiedChunk); + bodyByteLength += bufferifiedChunk.byteLength; + } else if (DEBUG_BUILD) { + logger.log( + INSTRUMENTATION_NAME, + `Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`, + ); } + } catch (err) { + DEBUG_BUILD && logger.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.'); } return Reflect.apply(target, thisArg, args); @@ -454,6 +431,19 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): return Reflect.apply(target, thisArg, args); }, }); + + req.on('end', () => { + try { + const body = Buffer.concat(chunks).toString('utf-8'); + if (body) { + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: body } }); + } + } catch (error) { + if (DEBUG_BUILD) { + logger.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); + } + } + }); } catch (error) { if (DEBUG_BUILD) { logger.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); From a3d02247d6b9d8cd4338f293fb92a06e962d8a21 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 23 Apr 2025 13:37:47 +0200 Subject: [PATCH 15/19] ref(core): Remove internal `utils-hoist` re-export (#16114) This gets rid of the internal `utils-hoist` barrel file and blanket re-export from it in core. --- packages/core/src/currentScopes.ts | 2 +- packages/core/src/index.ts | 150 +++++++++++++++++- packages/core/src/integrations/console.ts | 12 +- packages/core/src/integrations/supabase.ts | 4 +- packages/core/src/logs/console-integration.ts | 5 +- packages/core/src/logs/envelope.ts | 5 +- packages/core/src/logs/exports.ts | 3 +- packages/core/src/mcp-server.ts | 2 +- packages/core/src/server-runtime-client.ts | 2 +- packages/core/src/session.ts | 3 +- packages/core/src/trpc.ts | 2 +- packages/core/src/utils-hoist/index.ts | 140 ---------------- packages/core/test/lib/logs/envelope.test.ts | 21 +-- 13 files changed, 181 insertions(+), 170 deletions(-) delete mode 100644 packages/core/src/utils-hoist/index.ts diff --git a/packages/core/src/currentScopes.ts b/packages/core/src/currentScopes.ts index 6bcdca2ae17b..9dc1c164c387 100644 --- a/packages/core/src/currentScopes.ts +++ b/packages/core/src/currentScopes.ts @@ -3,7 +3,7 @@ import { getGlobalSingleton, getMainCarrier } from './carrier'; import type { Client } from './client'; import { Scope } from './scope'; import type { TraceContext } from './types-hoist'; -import { generateSpanId } from './utils-hoist'; +import { generateSpanId } from './utils-hoist/propagationContext'; /** * Get the currently active scope. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b5dac82ffa54..be2feb94ff2e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,9 +120,155 @@ export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from './logs/exports'; export { consoleLoggingIntegration } from './logs/console-integration'; -// TODO: Make this structure pretty again and don't do "export *" -export * from './utils-hoist/index'; // TODO: Make this structure pretty again and don't do "export *" export * from './types-hoist/index'; export type { FeatureFlag } from './featureFlags'; + +export { applyAggregateErrorsToEvent } from './utils-hoist/aggregate-errors'; +export { getBreadcrumbLogLevelFromHttpStatusCode } from './utils-hoist/breadcrumb-log-level'; +export { getComponentName, getLocationHref, htmlTreeAsString } from './utils-hoist/browser'; +export { dsnFromString, dsnToString, makeDsn } from './utils-hoist/dsn'; +// eslint-disable-next-line deprecation/deprecation +export { SentryError } from './utils-hoist/error'; +export { GLOBAL_OBJ } from './utils-hoist/worldwide'; +export type { InternalGlobal } from './utils-hoist/worldwide'; +export { addConsoleInstrumentationHandler } from './utils-hoist/instrument/console'; +export { addFetchEndInstrumentationHandler, addFetchInstrumentationHandler } from './utils-hoist/instrument/fetch'; +export { addGlobalErrorInstrumentationHandler } from './utils-hoist/instrument/globalError'; +export { addGlobalUnhandledRejectionInstrumentationHandler } from './utils-hoist/instrument/globalUnhandledRejection'; +export { + addHandler, + maybeInstrument, + resetInstrumentationHandlers, + triggerHandlers, +} from './utils-hoist/instrument/handlers'; +export { + isDOMError, + isDOMException, + isElement, + isError, + isErrorEvent, + isEvent, + isInstanceOf, + isParameterizedString, + isPlainObject, + isPrimitive, + isRegExp, + isString, + isSyntheticEvent, + isThenable, + isVueViewModel, +} from './utils-hoist/is'; +export { isBrowser } from './utils-hoist/isBrowser'; +export { CONSOLE_LEVELS, consoleSandbox, logger, originalConsoleMethods } from './utils-hoist/logger'; +export type { Logger } from './utils-hoist/logger'; +export { + addContextToFrame, + addExceptionMechanism, + addExceptionTypeValue, + checkOrSetAlreadyCaught, + getEventDescription, + parseSemver, + uuid4, +} from './utils-hoist/misc'; +export { isNodeEnv, loadModule } from './utils-hoist/node'; +export { normalize, normalizeToSize, normalizeUrlToBase } from './utils-hoist/normalize'; +export { + addNonEnumerableProperty, + convertToPlainObject, + // eslint-disable-next-line deprecation/deprecation + dropUndefinedKeys, + extractExceptionKeysForMessage, + fill, + getOriginalFunction, + markFunctionWrapped, + objectify, +} from './utils-hoist/object'; +export { basename, dirname, isAbsolute, join, normalizePath, relative, resolve } from './utils-hoist/path'; +export { makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils-hoist/promisebuffer'; +export type { PromiseBuffer } from './utils-hoist/promisebuffer'; +export { severityLevelFromString } from './utils-hoist/severity'; +export { + UNKNOWN_FUNCTION, + createStackParser, + getFramesFromEvent, + getFunctionName, + stackParserFromStackParserOptions, + stripSentryFramesAndReverse, +} from './utils-hoist/stacktrace'; +export { filenameIsInApp, node, nodeStackLineParser } from './utils-hoist/node-stack-trace'; +export { isMatchingPattern, safeJoin, snipLine, stringMatchesSomePattern, truncate } from './utils-hoist/string'; +export { + isNativeFunction, + supportsDOMError, + supportsDOMException, + supportsErrorEvent, + supportsFetch, + supportsHistory, + supportsNativeFetch, + supportsReferrerPolicy, + supportsReportingObserver, +} from './utils-hoist/supports'; +export { SyncPromise, rejectedSyncPromise, resolvedSyncPromise } from './utils-hoist/syncpromise'; +export { browserPerformanceTimeOrigin, dateTimestampInSeconds, timestampInSeconds } from './utils-hoist/time'; +export { + TRACEPARENT_REGEXP, + extractTraceparentData, + generateSentryTraceHeader, + propagationContextFromHeaders, +} from './utils-hoist/tracing'; +export { getSDKSource, isBrowserBundle } from './utils-hoist/env'; +export type { SdkSource } from './utils-hoist/env'; +export { + addItemToEnvelope, + createAttachmentEnvelopeItem, + createEnvelope, + createEventEnvelopeHeaders, + createSpanEnvelopeItem, + envelopeContainsItemType, + envelopeItemTypeToDataCategory, + forEachEnvelopeItem, + getSdkMetadataForEnvelopeHeader, + parseEnvelope, + serializeEnvelope, +} from './utils-hoist/envelope'; +export { createClientReportEnvelope } from './utils-hoist/clientreport'; +export { + DEFAULT_RETRY_AFTER, + disabledUntil, + isRateLimited, + parseRetryAfterHeader, + updateRateLimits, +} from './utils-hoist/ratelimit'; +export type { RateLimits } from './utils-hoist/ratelimit'; +export { + MAX_BAGGAGE_STRING_LENGTH, + SENTRY_BAGGAGE_KEY_PREFIX, + SENTRY_BAGGAGE_KEY_PREFIX_REGEX, + baggageHeaderToDynamicSamplingContext, + dynamicSamplingContextToSentryBaggageHeader, + parseBaggageHeader, + objectToBaggageHeader, +} from './utils-hoist/baggage'; +export { + getSanitizedUrlString, + parseUrl, + stripUrlQueryAndFragment, + parseStringToURLObject, + isURLObjectRelative, + getSanitizedUrlStringFromUrlObject, +} from './utils-hoist/url'; +export { + eventFromMessage, + eventFromUnknownInput, + exceptionFromError, + parseStackFrames, +} from './utils-hoist/eventbuilder'; +export { callFrameToStackFrame, watchdogTimer } from './utils-hoist/anr'; +export { LRUMap } from './utils-hoist/lru'; +export { generateTraceId, generateSpanId } from './utils-hoist/propagationContext'; +export { vercelWaitUntil } from './utils-hoist/vercelWaitUntil'; +export { SDK_VERSION } from './utils-hoist/version'; +export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils-hoist/debug-ids'; +export { escapeStringForRegex } from './utils-hoist/vendor/escapeStringForRegex'; diff --git a/packages/core/src/integrations/console.ts b/packages/core/src/integrations/console.ts index 3cd0bff04a1e..110b94d5fcab 100644 --- a/packages/core/src/integrations/console.ts +++ b/packages/core/src/integrations/console.ts @@ -2,13 +2,11 @@ import { addBreadcrumb } from '../breadcrumbs'; import { getClient } from '../currentScopes'; import { defineIntegration } from '../integration'; import type { ConsoleLevel } from '../types-hoist'; -import { - CONSOLE_LEVELS, - GLOBAL_OBJ, - addConsoleInstrumentationHandler, - safeJoin, - severityLevelFromString, -} from '../utils-hoist'; +import { addConsoleInstrumentationHandler } from '../utils-hoist/instrument/console'; +import { CONSOLE_LEVELS } from '../utils-hoist/logger'; +import { severityLevelFromString } from '../utils-hoist/severity'; +import { safeJoin } from '../utils-hoist/string'; +import { GLOBAL_OBJ } from '../utils-hoist/worldwide'; interface ConsoleIntegrationOptions { levels: ConsoleLevel[]; diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index f6541da51aa2..6cc6b5637c3e 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -2,8 +2,6 @@ // https://github.com/supabase-community/sentry-integration-js /* eslint-disable max-lines */ -import { logger, isPlainObject } from '../utils-hoist'; - import type { IntegrationFn } from '../types-hoist'; import { setHttpStatus, startSpan } from '../tracing'; import { addBreadcrumb } from '../breadcrumbs'; @@ -12,6 +10,8 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from ' import { captureException } from '../exports'; import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '../tracing'; import { DEBUG_BUILD } from '../debug-build'; +import { logger } from '../utils-hoist/logger'; +import { isPlainObject } from '../utils-hoist/is'; const AUTH_OPERATIONS_TO_INSTRUMENT = [ 'reauthenticate', diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index d0fe2a639738..fe1c5babefa6 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -3,7 +3,10 @@ import { DEBUG_BUILD } from '../debug-build'; import { defineIntegration } from '../integration'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import type { ConsoleLevel, IntegrationFn } from '../types-hoist'; -import { CONSOLE_LEVELS, GLOBAL_OBJ, addConsoleInstrumentationHandler, logger, safeJoin } from '../utils-hoist'; +import { CONSOLE_LEVELS, logger } from '../utils-hoist/logger'; +import { GLOBAL_OBJ } from '../utils-hoist/worldwide'; +import { addConsoleInstrumentationHandler } from '../utils-hoist/instrument/console'; +import { safeJoin } from '../utils-hoist/string'; import { _INTERNAL_captureLog } from './exports'; interface CaptureConsoleOptions { diff --git a/packages/core/src/logs/envelope.ts b/packages/core/src/logs/envelope.ts index 1b0a58892546..706596a60dcb 100644 --- a/packages/core/src/logs/envelope.ts +++ b/packages/core/src/logs/envelope.ts @@ -1,8 +1,7 @@ -import { createEnvelope } from '../utils-hoist'; - import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist'; import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope'; -import { dsnToString } from '../utils-hoist'; +import { dsnToString } from '../utils-hoist/dsn'; +import { createEnvelope } from '../utils-hoist/envelope'; /** * Creates OTEL log envelope item for a serialized OTEL log. diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index 4864f3b32b8e..62b37b2304f5 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -5,9 +5,10 @@ import { DEBUG_BUILD } from '../debug-build'; import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist'; import type { Log } from '../types-hoist/log'; -import { isParameterizedString, logger } from '../utils-hoist'; import { _getSpanForScope } from '../utils/spanOnScope'; import { createOtelLogEnvelope } from './envelope'; +import { logger } from '../utils-hoist/logger'; +import { isParameterizedString } from '../utils-hoist/is'; const MAX_LOG_BUFFER_SIZE = 100; diff --git a/packages/core/src/mcp-server.ts b/packages/core/src/mcp-server.ts index 85e9428853e2..3290b5b674b1 100644 --- a/packages/core/src/mcp-server.ts +++ b/packages/core/src/mcp-server.ts @@ -5,7 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from './semanticAttributes'; import { startSpan } from './tracing'; -import { logger } from './utils-hoist'; +import { logger } from './utils-hoist/logger'; interface MCPServerInstance { // The first arg is always a name, the last arg should always be a callback function (ie a handler). diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index b7910ed23d0a..fde0dac07cc2 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -23,7 +23,7 @@ import { logger } from './utils-hoist/logger'; import { uuid4 } from './utils-hoist/misc'; import { resolvedSyncPromise } from './utils-hoist/syncpromise'; import { _INTERNAL_flushLogsBuffer } from './logs/exports'; -import { isPrimitive } from './utils-hoist'; +import { isPrimitive } from './utils-hoist/is'; // TODO: Make this configurable const DEFAULT_LOG_FLUSH_INTERVAL = 5000; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a1ea29448fa4..7f5d770603a8 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,5 +1,6 @@ import type { SerializedSession, Session, SessionContext, SessionStatus } from './types-hoist'; -import { timestampInSeconds, uuid4 } from './utils-hoist'; +import { uuid4 } from './utils-hoist/misc'; +import { timestampInSeconds } from './utils-hoist/time'; /** * Creates a new `Session` object by setting certain default parameters. If optional @param context diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts index 59f3ecbdd18b..d41030b22dd6 100644 --- a/packages/core/src/trpc.ts +++ b/packages/core/src/trpc.ts @@ -2,8 +2,8 @@ import { getClient, withScope } from './currentScopes'; import { captureException } from './exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { startSpanManual } from './tracing'; -import { addNonEnumerableProperty } from './utils-hoist'; import { normalize } from './utils-hoist/normalize'; +import { addNonEnumerableProperty } from './utils-hoist/object'; interface SentryTrpcMiddlewareOptions { /** Whether to include procedure inputs in reported events. Defaults to `false`. */ diff --git a/packages/core/src/utils-hoist/index.ts b/packages/core/src/utils-hoist/index.ts deleted file mode 100644 index 2bb15f1423dc..000000000000 --- a/packages/core/src/utils-hoist/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -export { applyAggregateErrorsToEvent } from './aggregate-errors'; -export { getBreadcrumbLogLevelFromHttpStatusCode } from './breadcrumb-log-level'; -export { getComponentName, getLocationHref, htmlTreeAsString } from './browser'; -export { dsnFromString, dsnToString, makeDsn } from './dsn'; -// eslint-disable-next-line deprecation/deprecation -export { SentryError } from './error'; -export { GLOBAL_OBJ } from './worldwide'; -export type { InternalGlobal } from './worldwide'; -export { addConsoleInstrumentationHandler } from './instrument/console'; -export { addFetchEndInstrumentationHandler, addFetchInstrumentationHandler } from './instrument/fetch'; -export { addGlobalErrorInstrumentationHandler } from './instrument/globalError'; -export { addGlobalUnhandledRejectionInstrumentationHandler } from './instrument/globalUnhandledRejection'; -export { addHandler, maybeInstrument, resetInstrumentationHandlers, triggerHandlers } from './instrument/handlers'; -export { - isDOMError, - isDOMException, - isElement, - isError, - isErrorEvent, - isEvent, - isInstanceOf, - isParameterizedString, - isPlainObject, - isPrimitive, - isRegExp, - isString, - isSyntheticEvent, - isThenable, - isVueViewModel, -} from './is'; -export { isBrowser } from './isBrowser'; -export { CONSOLE_LEVELS, consoleSandbox, logger, originalConsoleMethods } from './logger'; -export type { Logger } from './logger'; - -export { - addContextToFrame, - addExceptionMechanism, - addExceptionTypeValue, - checkOrSetAlreadyCaught, - getEventDescription, - parseSemver, - uuid4, -} from './misc'; -export { isNodeEnv, loadModule } from './node'; -export { normalize, normalizeToSize, normalizeUrlToBase } from './normalize'; -export { - addNonEnumerableProperty, - convertToPlainObject, - // eslint-disable-next-line deprecation/deprecation - dropUndefinedKeys, - extractExceptionKeysForMessage, - fill, - getOriginalFunction, - markFunctionWrapped, - objectify, -} from './object'; -export { basename, dirname, isAbsolute, join, normalizePath, relative, resolve } from './path'; -export { makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './promisebuffer'; -export type { PromiseBuffer } from './promisebuffer'; - -export { severityLevelFromString } from './severity'; -export { - UNKNOWN_FUNCTION, - createStackParser, - getFramesFromEvent, - getFunctionName, - stackParserFromStackParserOptions, - stripSentryFramesAndReverse, -} from './stacktrace'; -export { filenameIsInApp, node, nodeStackLineParser } from './node-stack-trace'; -export { isMatchingPattern, safeJoin, snipLine, stringMatchesSomePattern, truncate } from './string'; -export { - isNativeFunction, - supportsDOMError, - supportsDOMException, - supportsErrorEvent, - supportsFetch, - supportsHistory, - supportsNativeFetch, - supportsReferrerPolicy, - supportsReportingObserver, -} from './supports'; -export { SyncPromise, rejectedSyncPromise, resolvedSyncPromise } from './syncpromise'; -export { browserPerformanceTimeOrigin, dateTimestampInSeconds, timestampInSeconds } from './time'; -export { - TRACEPARENT_REGEXP, - extractTraceparentData, - generateSentryTraceHeader, - propagationContextFromHeaders, -} from './tracing'; -export { getSDKSource, isBrowserBundle } from './env'; -export type { SdkSource } from './env'; -export { - addItemToEnvelope, - createAttachmentEnvelopeItem, - createEnvelope, - createEventEnvelopeHeaders, - createSpanEnvelopeItem, - envelopeContainsItemType, - envelopeItemTypeToDataCategory, - forEachEnvelopeItem, - getSdkMetadataForEnvelopeHeader, - parseEnvelope, - serializeEnvelope, -} from './envelope'; -export { createClientReportEnvelope } from './clientreport'; -export { - DEFAULT_RETRY_AFTER, - disabledUntil, - isRateLimited, - parseRetryAfterHeader, - updateRateLimits, -} from './ratelimit'; -export type { RateLimits } from './ratelimit'; -export { - MAX_BAGGAGE_STRING_LENGTH, - SENTRY_BAGGAGE_KEY_PREFIX, - SENTRY_BAGGAGE_KEY_PREFIX_REGEX, - baggageHeaderToDynamicSamplingContext, - dynamicSamplingContextToSentryBaggageHeader, - parseBaggageHeader, - objectToBaggageHeader, -} from './baggage'; - -export { - getSanitizedUrlString, - parseUrl, - stripUrlQueryAndFragment, - parseStringToURLObject, - isURLObjectRelative, - getSanitizedUrlStringFromUrlObject, -} from './url'; -export { eventFromMessage, eventFromUnknownInput, exceptionFromError, parseStackFrames } from './eventbuilder'; -export { callFrameToStackFrame, watchdogTimer } from './anr'; -export { LRUMap } from './lru'; -export { generateTraceId, generateSpanId } from './propagationContext'; -export { vercelWaitUntil } from './vercelWaitUntil'; -export { SDK_VERSION } from './version'; -export { getDebugImagesForResources, getFilenameToDebugIdMap } from './debug-ids'; -export { escapeStringForRegex } from './vendor/escapeStringForRegex'; diff --git a/packages/core/test/lib/logs/envelope.test.ts b/packages/core/test/lib/logs/envelope.test.ts index 49bca586430c..b50ca60c9a1c 100644 --- a/packages/core/test/lib/logs/envelope.test.ts +++ b/packages/core/test/lib/logs/envelope.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { createOtelLogEnvelope, createOtelLogEnvelopeItem } from '../../../src/logs/envelope'; import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../../../src/types-hoist'; -import * as utilsHoist from '../../../src/utils-hoist'; +import * as utilsDsn from '../../../src/utils-hoist/dsn'; +import * as utilsEnvelope from '../../../src/utils-hoist/envelope'; // Mock utils-hoist functions -vi.mock('../../../src/utils-hoist', () => ({ - createEnvelope: vi.fn((_headers, items) => [_headers, items]), +vi.mock('../../../src/utils-hoist/dsn', () => ({ dsnToString: vi.fn(dsn => `https://${dsn.publicKey}@${dsn.host}/`), })); +vi.mock('../../../src/utils-hoist/envelope', () => ({ + createEnvelope: vi.fn((_headers, items) => [_headers, items]), +})); describe('createOtelLogEnvelopeItem', () => { it('creates an envelope item with correct structure', () => { @@ -32,8 +35,8 @@ describe('createOtelLogEnvelope', () => { vi.setSystemTime(new Date('2023-01-01T12:00:00Z')); // Reset mocks - vi.mocked(utilsHoist.createEnvelope).mockClear(); - vi.mocked(utilsHoist.dsnToString).mockClear(); + vi.mocked(utilsEnvelope.createEnvelope).mockClear(); + vi.mocked(utilsDsn.dsnToString).mockClear(); }); afterEach(() => { @@ -53,7 +56,7 @@ describe('createOtelLogEnvelope', () => { expect(result[0]).toEqual({}); // Verify createEnvelope was called with the right parameters - expect(utilsHoist.createEnvelope).toHaveBeenCalledWith({}, expect.any(Array)); + expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith({}, expect.any(Array)); }); it('includes SDK info when metadata is provided', () => { @@ -101,7 +104,7 @@ describe('createOtelLogEnvelope', () => { const result = createOtelLogEnvelope(mockLogs, undefined, 'https://tunnel.example.com', dsn); expect(result[0]).toHaveProperty('dsn'); - expect(utilsHoist.dsnToString).toHaveBeenCalledWith(dsn); + expect(utilsDsn.dsnToString).toHaveBeenCalledWith(dsn); }); it('maps each log to an envelope item', () => { @@ -119,7 +122,7 @@ describe('createOtelLogEnvelope', () => { createOtelLogEnvelope(mockLogs); // Check that createEnvelope was called with an array of envelope items - expect(utilsHoist.createEnvelope).toHaveBeenCalledWith( + expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ expect.arrayContaining([{ type: 'otel_log' }, mockLogs[0]]), @@ -166,7 +169,7 @@ describe('Trace context in logs', () => { createOtelLogEnvelope([mockLog]); // Verify the envelope preserves the trace information - expect(utilsHoist.createEnvelope).toHaveBeenCalledWith( + expect(utilsEnvelope.createEnvelope).toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([ expect.arrayContaining([ From 7438608bb4c1f3e9c010c6480ef343388445518c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 23 Apr 2025 13:44:40 +0200 Subject: [PATCH 16/19] feat(nextjs): Improve server component data (#15996) --- .../nextjs-app-dir/tests/server-components.test.ts | 8 ++++++++ .../nextjs/src/common/wrapServerComponentWithSentry.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index f2e73c892a9b..f65cf7dbc1c1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -79,6 +79,10 @@ test('Should set a "not_found" status on a server component span when notFound() description: 'Page Server Component (/server-component/not-found)', op: 'function.nextjs', status: 'not_found', + data: expect.objectContaining({ + 'sentry.nextjs.function.type': 'Page', + 'sentry.nextjs.function.route': '/server-component/not-found', + }), }), ); }); @@ -107,6 +111,10 @@ test('Should capture an error and transaction for a app router page', async ({ p description: 'Page Server Component (/server-component/faulty)', op: 'function.nextjs', status: 'internal_error', + data: expect.objectContaining({ + 'sentry.nextjs.function.type': 'Page', + 'sentry.nextjs.function.route': '/server-component/faulty', + }), }), ); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index d4dce97979f9..6fbc78011bea 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -89,6 +89,8 @@ export function wrapServerComponentWithSentry any> attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + 'sentry.nextjs.function.type': componentType, + 'sentry.nextjs.function.route': componentRoute, }, }, span => { From 20ce849fec6f246725c6c4507b37fa2d2b871207 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 23 Apr 2025 13:54:06 +0200 Subject: [PATCH 17/19] feat(nestjs): Gracefully handle RPC scenarios in `SentryGlobalFilter` (#16066) --- packages/nestjs/src/setup.ts | 37 ++- .../nestjs/test/sentry-global-filter.test.ts | 235 ++++++++++++++++++ 2 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 packages/nestjs/test/sentry-global-filter.test.ts diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 045c196a0b8c..13d44e74a204 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -86,10 +86,12 @@ class SentryGlobalFilter extends BaseExceptionFilter { * Catches exceptions and reports them to Sentry unless they are expected errors. */ public catch(exception: unknown, host: ArgumentsHost): void { + const contextType = host.getType(); + // The BaseExceptionFilter does not work well in GraphQL applications. // By default, Nest GraphQL applications use the ExternalExceptionFilter, which just rethrows the error: // https://github.com/nestjs/nest/blob/master/packages/core/exceptions/external-exception-filter.ts - if (host.getType<'graphql'>() === 'graphql') { + if (contextType === 'graphql') { // neither report nor log HttpExceptions if (exception instanceof HttpException) { throw exception; @@ -103,6 +105,39 @@ class SentryGlobalFilter extends BaseExceptionFilter { throw exception; } + // Handle microservice context (rpc) + // We cannot add proper handing here since RpcException depend on the @nestjs/microservices package + // For these cases we log a warning that the user should be providing a dedicated exception filter + if (contextType === 'rpc') { + // Unlikely case + if (exception instanceof HttpException) { + throw exception; + } + + // Handle any other kind of error + if (!(exception instanceof Error)) { + if (!isExpectedError(exception)) { + captureException(exception); + } + throw exception; + } + + // In this case we're likely running into an RpcException, which the user should handle with a dedicated filter + // https://github.com/nestjs/nest/blob/master/sample/03-microservices/src/common/filters/rpc-exception.filter.ts + if (!isExpectedError(exception)) { + captureException(exception); + } + + this._logger.warn( + 'IMPORTANT: RpcException should be handled with a dedicated Rpc exception filter, not the generic SentryGlobalFilter', + ); + + // Log the error and return, otherwise we may crash the user's app by handling rpc errors in a http context + this._logger.error(exception.message, exception.stack); + return; + } + + // HTTP exceptions if (!isExpectedError(exception)) { captureException(exception); } diff --git a/packages/nestjs/test/sentry-global-filter.test.ts b/packages/nestjs/test/sentry-global-filter.test.ts new file mode 100644 index 000000000000..f144e9fad8ec --- /dev/null +++ b/packages/nestjs/test/sentry-global-filter.test.ts @@ -0,0 +1,235 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { ArgumentsHost } from '@nestjs/common'; +import { HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { SentryGlobalFilter } from '../src/setup'; +import * as SentryCore from '@sentry/core'; +import * as Helpers from '../src/helpers'; + +vi.mock('../src/helpers', () => ({ + isExpectedError: vi.fn(), +})); + +vi.mock('@sentry/core', () => ({ + captureException: vi.fn().mockReturnValue('mock-event-id'), + getIsolationScope: vi.fn(), + getDefaultIsolationScope: vi.fn(), + logger: { + warn: vi.fn(), + }, +})); + +describe('SentryGlobalFilter', () => { + let filter: SentryGlobalFilter; + let mockArgumentsHost: ArgumentsHost; + let mockHttpServer: any; + let mockCaptureException: any; + let mockLoggerError: any; + let mockLoggerWarn: any; + let isExpectedErrorMock: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockHttpServer = { + getRequestMethod: vi.fn(), + getRequestUrl: vi.fn(), + }; + + filter = new SentryGlobalFilter(mockHttpServer); + + mockArgumentsHost = { + getType: vi.fn().mockReturnValue('http'), + getArgs: vi.fn().mockReturnValue([]), + getArgByIndex: vi.fn().mockReturnValue({}), + switchToHttp: vi.fn().mockReturnValue({ + getRequest: vi.fn().mockReturnValue({}), + getResponse: vi.fn().mockReturnValue({}), + getNext: vi.fn(), + }), + switchToRpc: vi.fn(), + switchToWs: vi.fn(), + } as unknown as ArgumentsHost; + + mockLoggerError = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + mockLoggerWarn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + + mockCaptureException = vi.spyOn(SentryCore, 'captureException').mockReturnValue('mock-event-id'); + + isExpectedErrorMock = vi.mocked(Helpers.isExpectedError).mockImplementation(() => false); + }); + + describe('HTTP context', () => { + beforeEach(() => { + vi.mocked(mockArgumentsHost.getType).mockReturnValue('http'); + }); + + it('should capture non-HttpException errors and call super.catch for HTTP context', () => { + const originalCatch = filter.catch; + const superCatchSpy = vi.fn(); + filter.catch = function (exception, host) { + if (!Helpers.isExpectedError(exception)) { + SentryCore.captureException(exception); + } + superCatchSpy(exception, host); + return {} as any; + }; + + const error = new Error('Test error'); + + filter.catch(error, mockArgumentsHost); + + expect(mockCaptureException).toHaveBeenCalledWith(error); + expect(superCatchSpy).toHaveBeenCalled(); + + filter.catch = originalCatch; + }); + + it('should not capture expected errors', () => { + const originalCatch = filter.catch; + const superCatchSpy = vi.fn(); + + isExpectedErrorMock.mockReturnValueOnce(true); + + filter.catch = function (exception, host) { + if (!Helpers.isExpectedError(exception)) { + SentryCore.captureException(exception); + } + superCatchSpy(exception, host); + return {} as any; + }; + + const expectedError = new Error('Test error'); + + filter.catch(expectedError, mockArgumentsHost); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(superCatchSpy).toHaveBeenCalled(); + + filter.catch = originalCatch; + }); + }); + + describe('GraphQL context', () => { + beforeEach(() => { + vi.mocked(mockArgumentsHost.getType).mockReturnValue('graphql'); + }); + + it('should throw HttpExceptions without capturing them', () => { + const httpException = new HttpException('Test HTTP exception', HttpStatus.BAD_REQUEST); + + expect(() => { + filter.catch(httpException, mockArgumentsHost); + }).toThrow(httpException); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockLoggerError).not.toHaveBeenCalled(); + }); + + it('should log and capture non-HttpException errors in GraphQL context', () => { + const error = new Error('Test error'); + + expect(() => { + filter.catch(error, mockArgumentsHost); + }).toThrow(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error); + expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack); + }); + }); + + describe('RPC context', () => { + beforeEach(() => { + vi.mocked(mockArgumentsHost.getType).mockReturnValue('rpc'); + }); + + it('should log a warning for RPC exceptions', () => { + const error = new Error('Test RPC error'); + + const originalCatch = filter.catch; + filter.catch = function (exception, _host) { + if (!Helpers.isExpectedError(exception)) { + SentryCore.captureException(exception); + } + + if (exception instanceof Error) { + mockLoggerError(exception.message, exception.stack); + } + + mockLoggerWarn( + 'IMPORTANT: RpcException should be handled with a dedicated Rpc exception filter, not the generic SentryGlobalFilter', + ); + + return undefined as any; + }; + + filter.catch(error, mockArgumentsHost); + + expect(mockCaptureException).toHaveBeenCalledWith(error); + expect(mockLoggerWarn).toHaveBeenCalled(); + expect(mockLoggerError).toHaveBeenCalledWith(error.message, error.stack); + + filter.catch = originalCatch; + }); + + it('should not capture expected RPC errors', () => { + isExpectedErrorMock.mockReturnValueOnce(true); + + const originalCatch = filter.catch; + filter.catch = function (exception, _host) { + if (!Helpers.isExpectedError(exception)) { + SentryCore.captureException(exception); + } + + if (exception instanceof Error) { + mockLoggerError(exception.message, exception.stack); + } + + mockLoggerWarn( + 'IMPORTANT: RpcException should be handled with a dedicated Rpc exception filter, not the generic SentryGlobalFilter', + ); + + return undefined as any; + }; + + const expectedError = new Error('Expected RPC error'); + + filter.catch(expectedError, mockArgumentsHost); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalled(); + expect(mockLoggerError).toHaveBeenCalledWith(expectedError.message, expectedError.stack); + + filter.catch = originalCatch; + }); + + it('should handle non-Error objects in RPC context', () => { + const nonErrorObject = { message: 'Not an Error object' }; + + const originalCatch = filter.catch; + filter.catch = function (exception, _host) { + if (!Helpers.isExpectedError(exception)) { + SentryCore.captureException(exception); + } + + return undefined as any; + }; + + filter.catch(nonErrorObject, mockArgumentsHost); + + expect(mockCaptureException).toHaveBeenCalledWith(nonErrorObject); + + filter.catch = originalCatch; + }); + + it('should throw HttpExceptions in RPC context without capturing', () => { + const httpException = new HttpException('Test HTTP exception', HttpStatus.BAD_REQUEST); + + expect(() => { + filter.catch(httpException, mockArgumentsHost); + }).toThrow(httpException); + + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + }); +}); From 749eda9036dfdd1fca5cd4302bdba97242bf76eb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 23 Apr 2025 14:41:04 +0200 Subject: [PATCH 18/19] meta(changelog): Update changelog for 9.14.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e5e08bc7f9..4ab37d17016c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,31 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.14.0 + +### Important Changes + +- **feat: Add Supabase Integration ([#15719](https://github.com/getsentry/sentry-javascript/pull/15719))** + +This PR adds Supabase integration to `@sentry/core`, allowing automatic instrumentation of Supabase client operations (database queries and authentication) for performance monitoring and error tracking. + +- **feat(nestjs): Gracefully handle RPC scenarios in `SentryGlobalFilter` ([#16066](https://github.com/getsentry/sentry-javascript/pull/16066))** + +This PR adds better RPC exception handling to `@sentry/nestjs`, preventing application crashes while still capturing errors and warning users when a dedicated filter is needed. The implementation gracefully handles the 'rpc' context type in `SentryGlobalFilter` to improve reliability in hybrid applications. + +- **feat(react-router): Trace propagation ([#16070](https://github.com/getsentry/sentry-javascript/pull/16070))** + +This PR adds trace propagation to `@sentry/react-router` by providing utilities to inject trace meta tags into HTML headers and offering a pre-built Sentry-instrumented request handler, improving distributed tracing capabilities across page loads. + +### Other Changes + +- feat(deps): Bump @prisma/instrumentation from 6.5.0 to 6.6.0 ([#16102](https://github.com/getsentry/sentry-javascript/pull/16102)) +- feat(nextjs): Improve server component data ([#15996](https://github.com/getsentry/sentry-javascript/pull/15996)) +- feat(nuxt): Log when adding HTML trace meta tags ([#16044](https://github.com/getsentry/sentry-javascript/pull/16044)) +- fix(node): Make body capturing more robust ([#16105](https://github.com/getsentry/sentry-javascript/pull/16105)) +- ref(core): Remove internal `utils-hoist` re-export ([#16114](https://github.com/getsentry/sentry-javascript/pull/16114)) +- ref(node): Log when incoming request bodies are being captured ([#16104](https://github.com/getsentry/sentry-javascript/pull/16104)) + ## 9.13.0 ### Important Changes From 7c0365ccd555655c1edd92deac726994bec8dd7d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 23 Apr 2025 16:07:37 +0200 Subject: [PATCH 19/19] rm ref pr --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab37d17016c..c3f6fcc8c473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,6 @@ This PR adds trace propagation to `@sentry/react-router` by providing utilities - feat(nextjs): Improve server component data ([#15996](https://github.com/getsentry/sentry-javascript/pull/15996)) - feat(nuxt): Log when adding HTML trace meta tags ([#16044](https://github.com/getsentry/sentry-javascript/pull/16044)) - fix(node): Make body capturing more robust ([#16105](https://github.com/getsentry/sentry-javascript/pull/16105)) -- ref(core): Remove internal `utils-hoist` re-export ([#16114](https://github.com/getsentry/sentry-javascript/pull/16114)) - ref(node): Log when incoming request bodies are being captured ([#16104](https://github.com/getsentry/sentry-javascript/pull/16104)) ## 9.13.0