diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index bcec195ffa5e..ef0f0344b8fc 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,20 +68,20 @@ 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": { - "label": "Hapi + "@sentry.node.-.hapi": { + "label": "Hapi" }, - "@sentry.node - connect": { - "label": "Connect + "@sentry.node.-.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" @@ -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": "Loader Script" + }, + "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.additionalLabel.outputs.label }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e5e08bc7f9..c3f6fcc8c473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,30 @@ - "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(node): Log when incoming request bodies are being captured ([#16104](https://github.com/getsentry/sentry-javascript/pull/16104)) + ## 9.13.0 ### Important Changes 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/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" 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/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" }, 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/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); }); }); 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/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/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(); }); 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/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 71a8b03acacb..be2feb94ff2e 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'; @@ -119,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 new file mode 100644 index 000000000000..6cc6b5637c3e --- /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 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'; +import { logger } from '../utils-hoist/logger'; +import { isPlainObject } from '../utils-hoist/is'; + +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/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([ 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/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(); + }); + }); +}); 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 => { 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/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/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 0e0502b6fd1f..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, @@ -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 */ @@ -358,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 @@ -377,41 +376,28 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): apply: (target, thisArg, args: Parameters) => { const [event, listener, ...restArgs] = args; - 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( - `Dropping request body chunk because it 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 (DEBUG_BUILD) { + logger.log(INSTRUMENTATION_NAME, 'Patching request.on', event); } - if (event === 'end') { + if (event === 'data') { const callback = new Proxy(listener, { - apply: (target, thisArg, args) => { + apply: (target, thisArg, args: Parameters) => { try { - const body = Buffer.concat(chunks).toString('utf-8'); - - if (body) { - const normalizedRequest = { data: body } satisfies RequestEventData; - isolationScope.setSDKProcessingMetadata({ normalizedRequest }); + 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 { - // ignore errors here + } catch (err) { + DEBUG_BUILD && logger.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.'); } return Reflect.apply(target, thisArg, args); @@ -445,8 +431,23 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): return Reflect.apply(target, thisArg, args); }, }); - } catch { - // ignore errors if we can't patch stuff + + 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); + } } } 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(() => { 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" } } 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..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" @@ -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== @@ -27018,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"