diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 94db1a39eb..3e8039e5f2 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -40,7 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ echo 'exec "$@"' >> /tmp/read-secrets.sh && \ chmod +x /tmp/read-secrets.sh -ARG NEXT_PUBLIC_SENTRY_DSN ARG SENTRY_AUTH_TOKEN # Increase Node.js memory limit as a regular build argument diff --git a/apps/web/app/layout.test.tsx b/apps/web/app/layout.test.tsx index 51abc5195b..495ec8f9ce 100644 --- a/apps/web/app/layout.test.tsx +++ b/apps/web/app/layout.test.tsx @@ -29,6 +29,7 @@ vi.mock("@formbricks/lib/constants", () => ({ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", })); vi.mock("@/tolgee/language", () => ({ @@ -69,6 +70,15 @@ vi.mock("@/tolgee/client", () => ({ ), })); +vi.mock("@/app/sentry/SentryProvider", () => ({ + SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => ( +
+ SentryProvider: {sentryDsn} + {children} +
+ ), +})); + describe("RootLayout", () => { beforeEach(() => { cleanup(); @@ -97,6 +107,7 @@ describe("RootLayout", () => { expect(screen.getByTestId("speed-insights")).toBeInTheDocument(); expect(screen.getByTestId("ph-provider")).toBeInTheDocument(); expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument(); + expect(screen.getByTestId("sentry-provider")).toBeInTheDocument(); expect(screen.getByTestId("child")).toHaveTextContent("Child Content"); }); }); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index ede4738cd7..bc235ff730 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,3 +1,4 @@ +import { SentryProvider } from "@/app/sentry/SentryProvider"; import { PHProvider } from "@/modules/ui/components/post-hog-client"; import { TolgeeNextProvider } from "@/tolgee/client"; import { getLocale } from "@/tolgee/language"; @@ -6,7 +7,7 @@ import { TolgeeStaticData } from "@tolgee/react"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { Metadata } from "next"; import React from "react"; -import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants"; +import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants"; import "../modules/ui/globals.css"; export const metadata: Metadata = { @@ -27,11 +28,13 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => { {process.env.VERCEL === "1" && } - - - {children} - - + + + + {children} + + + ); diff --git a/apps/web/app/sentry/SentryProvider.test.tsx b/apps/web/app/sentry/SentryProvider.test.tsx new file mode 100644 index 0000000000..40b58e7165 --- /dev/null +++ b/apps/web/app/sentry/SentryProvider.test.tsx @@ -0,0 +1,101 @@ +import * as Sentry from "@sentry/nextjs"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SentryProvider } from "./SentryProvider"; + +vi.mock("@sentry/nextjs", async () => { + const actual = await vi.importActual("@sentry/nextjs"); + return { + ...actual, + replayIntegration: (options: any) => { + return { + name: "Replay", + id: "Replay", + options, + }; + }, + }; +}); + +describe("SentryProvider", () => { + afterEach(() => { + cleanup(); + }); + + it("calls Sentry.init when sentryDsn is provided", () => { + const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + + render( + +
Test Content
+
+ ); + + // The useEffect runs after mount, so Sentry.init should have been called. + expect(initSpy).toHaveBeenCalled(); + expect(initSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: sentryDsn, + tracesSampleRate: 1, + debug: false, + replaysOnErrorSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + integrations: expect.any(Array), + beforeSend: expect.any(Function), + }) + ); + }); + + it("does not call Sentry.init when sentryDsn is not provided", () => { + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + + render( + +
Test Content
+
+ ); + + expect(initSpy).not.toHaveBeenCalled(); + }); + + it("renders children", () => { + const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + render( + +
Test Content
+
+ ); + expect(screen.getByTestId("child")).toHaveTextContent("Test Content"); + }); + + it("processes beforeSend correctly", () => { + const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + + render( + +
Test Content
+
+ ); + + const config = initSpy.mock.calls[0][0]; + expect(config).toHaveProperty("beforeSend"); + const beforeSend = config.beforeSend; + + if (!beforeSend) { + throw new Error("beforeSend is not defined"); + } + + const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent; + + const hintWithNextNotFound = { originalException: { digest: "NEXT_NOT_FOUND" } }; + expect(beforeSend(dummyEvent, hintWithNextNotFound)).toBeNull(); + + const hintWithOtherError = { originalException: { digest: "OTHER_ERROR" } }; + expect(beforeSend(dummyEvent, hintWithOtherError)).toEqual(dummyEvent); + + const hintWithoutError = { originalException: undefined }; + expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent); + }); +}); diff --git a/apps/web/app/sentry/SentryProvider.tsx b/apps/web/app/sentry/SentryProvider.tsx new file mode 100644 index 0000000000..b01e71dfc4 --- /dev/null +++ b/apps/web/app/sentry/SentryProvider.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect } from "react"; + +interface SentryProviderProps { + children: React.ReactNode; + sentryDsn?: string; +} + +export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) => { + useEffect(() => { + if (sentryDsn) { + Sentry.init({ + dsn: sentryDsn, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + replaysOnErrorSampleRate: 1.0, + + // 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, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + integrations: [ + Sentry.replayIntegration({ + // Additional Replay configuration goes in here, for example: + maskAllText: true, + blockAllMedia: true, + }), + ], + + beforeSend(event, hint) { + const error = hint.originalException as Error; + + // @ts-expect-error + if (error && error.digest === "NEXT_NOT_FOUND") { + return null; + } + + return event; + }, + }); + } + }, []); + + return <>{children}; +}; diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts index 0b527429c2..e86284efd3 100644 --- a/apps/web/instrumentation.ts +++ b/apps/web/instrumentation.ts @@ -1,8 +1,14 @@ -import { env } from "@formbricks/lib/env"; +import { PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants"; // instrumentation.ts export const register = async () => { - if (process.env.NEXT_RUNTIME === "nodejs" && env.PROMETHEUS_ENABLED) { + if (process.env.NEXT_RUNTIME === "nodejs" && PROMETHEUS_ENABLED) { await import("./instrumentation-node"); } + if (process.env.NEXT_RUNTIME === "nodejs" && SENTRY_DSN) { + await import("./sentry.server.config"); + } + if (process.env.NEXT_RUNTIME === "edge" && SENTRY_DSN) { + await import("./sentry.edge.config"); + } }; diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index eb1eed2cfa..7eb8d1a5f8 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -321,7 +321,7 @@ const sentryConfig = { disableLogger: true, }; -const exportConfig = process.env.NEXT_PUBLIC_SENTRY_DSN +const exportConfig = process.env.SENTRY_DSN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig; diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts deleted file mode 100644 index b5765e8e3c..0000000000 --- a/apps/web/sentry.client.config.ts +++ /dev/null @@ -1,40 +0,0 @@ -// 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: process.env.NEXT_PUBLIC_SENTRY_DSN, - - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - replaysOnErrorSampleRate: 1.0, - - // 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, - - // You can remove this option if you're not planning to use the Sentry Session Replay feature: - integrations: [ - Sentry.replayIntegration({ - // Additional Replay configuration goes in here, for example: - maskAllText: true, - blockAllMedia: true, - }), - ], - - beforeSend(event, hint) { - const error = hint.originalException as Error; - - // @ts-expect-error - if (error && error.digest === "NEXT_NOT_FOUND") { - return null; - } - - return event; - }, -}); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts index 45a2a3a210..c882e14790 100644 --- a/apps/web/sentry.edge.config.ts +++ b/apps/web/sentry.edge.config.ts @@ -3,13 +3,20 @@ // 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"; +import { SENTRY_DSN } from "@formbricks/lib/constants"; -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, +if (SENTRY_DSN) { + console.log("Sentry DSN found, enabling Sentry on the edge"); - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + Sentry.init({ + dsn: SENTRY_DSN, - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}); + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }); +} else { + console.warn("Sentry DSN not found, Sentry will be disabled on the edge"); +} diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts index f91917df8a..c7a59cf053 100644 --- a/apps/web/sentry.server.config.ts +++ b/apps/web/sentry.server.config.ts @@ -2,27 +2,34 @@ // 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"; +import { SENTRY_DSN } from "@formbricks/lib/constants"; -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, +if (SENTRY_DSN) { + console.log("Sentry DSN found, enabling Sentry on the server"); - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + Sentry.init({ + dsn: SENTRY_DSN, - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, - // uncomment the line below to enable Spotlight (https://spotlightjs.com) - // spotlight: process.env.NODE_ENV === 'development', + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, - beforeSend(event, hint) { - const error = hint.originalException as Error; + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: process.env.NODE_ENV === 'development', - // @ts-expect-error - if (error && error.digest === "NEXT_NOT_FOUND") { - return null; - } + beforeSend(event, hint) { + const error = hint.originalException as Error; - return event; - }, -}); + // @ts-expect-error + if (error && error.digest === "NEXT_NOT_FOUND") { + return null; + } + + return event; + }, + }); +} else { + console.warn("Sentry DSN not found, Sentry will be disabled on the server"); +} diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 25297608c9..38b8557e90 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -32,14 +32,13 @@ export default defineConfig({ "app/(app)/environments/**/components/PosthogIdentify.tsx", "app/(app)/(onboarding)/organizations/**/layout.tsx", "app/(app)/(survey-editor)/environments/**/layout.tsx", - "modules/ee/sso/lib/**/*.ts", - "modules/ee/contacts/lib/**/*.ts", - "modules/survey/link/lib/**/*.ts", "app/(auth)/layout.tsx", "app/(app)/layout.tsx", "app/layout.tsx", - "app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx", "app/intercom/*.tsx", + "app/sentry/*.tsx", + "app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx", + "modules/ee/sso/lib/**/*.ts", "app/lib/**/*.ts", "app/api/(internal)/insights/lib/**/*.ts", "modules/ee/role-management/*.ts", diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 23e218c78d..3601858249 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -289,3 +289,7 @@ export const IS_TURNSTILE_CONFIGURED = Boolean(env.NEXT_PUBLIC_TURNSTILE_SITE_KE export const IS_PRODUCTION = env.NODE_ENV === "production"; export const IS_DEVELOPMENT = env.NODE_ENV === "development"; + +export const SENTRY_DSN = env.SENTRY_DSN; + +export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; diff --git a/packages/lib/env.ts b/packages/lib/env.ts index 069e77eb0a..e3318e6bd9 100644 --- a/packages/lib/env.ts +++ b/packages/lib/env.ts @@ -81,6 +81,7 @@ export const env = createEnv({ S3_ENDPOINT_URL: z.string().optional(), S3_FORCE_PATH_STYLE: z.enum(["1", "0"]).optional(), SAML_DATABASE_URL: z.string().optional(), + SENTRY_DSN: z.string().optional(), SIGNUP_DISABLED: z.enum(["1", "0"]).optional(), SLACK_CLIENT_ID: z.string().optional(), SLACK_CLIENT_SECRET: z.string().optional(), @@ -126,7 +127,6 @@ export const env = createEnv({ .or(z.string().refine((str) => str === "")), NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: z.string().optional(), NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: z.string().optional(), - NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(), }, /* @@ -184,9 +184,9 @@ export const env = createEnv({ NEXT_PUBLIC_FORMBRICKS_API_HOST: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST, NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID, + SENTRY_DSN: process.env.SENTRY_DSN, POSTHOG_API_KEY: process.env.POSTHOG_API_KEY, POSTHOG_API_HOST: process.env.POSTHOG_API_HOST, - NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL, INTERCOM_APP_ID: process.env.INTERCOM_APP_ID, diff --git a/packages/react-native/src/lib/survey/action.ts b/packages/react-native/src/lib/survey/action.ts index 1e3c6b3578..cb5a4bf325 100644 --- a/packages/react-native/src/lib/survey/action.ts +++ b/packages/react-native/src/lib/survey/action.ts @@ -103,6 +103,9 @@ export const track = async ( return trackAction(actionClass.name, code); } catch (error) { + const logger = Logger.getInstance(); + logger.error(`Error tracking action ${error as string}`); + return err({ code: "error", message: "Error tracking action", diff --git a/sonar-project.properties b/sonar-project.properties index a77ad428ae..3e6fde6dd1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false sonar.sourceEncoding=UTF-8 # Coverage -sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**,scripts/merge-client-endpoints.ts -sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts,scripts/merge-client-endpoints.ts +sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**, **/instrumentation.ts, scripts/merge-client-endpoints.ts +sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts, **/instrumentation.ts, scripts/merge-client-endpoints.ts \ No newline at end of file diff --git a/turbo.json b/turbo.json index a3339cfc5e..edf8e38705 100644 --- a/turbo.json +++ b/turbo.json @@ -159,7 +159,6 @@ "NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID", "NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID", "NEXT_PUBLIC_FORMBRICKS_URL", - "NEXT_PUBLIC_SENTRY_DSN", "NEXT_PUBLIC_FORMBRICKS_COM_API_HOST", "NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID", "NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID",