chore: Enable Sentry integration (#5337)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
victorvhs017
2025-04-21 19:41:54 +07:00
committed by GitHub
parent 6120f992a4
commit a32b213ca5
28 changed files with 294 additions and 56 deletions

View File

@@ -219,3 +219,8 @@ UNKEY_ROOT_KEY=
# PROMETHEUS_ENABLED=
# PROMETHEUS_EXPORTER_PORT=
# The SENTRY_DSN is used for error tracking and performance monitoring with Sentry.
# SENTRY_DSN=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=

2
apps/web/.gitignore vendored
View File

@@ -50,4 +50,4 @@ uploads/
.sentryclirc
# SAML Preloaded Connections
saml-connection/
saml-connection/

View File

@@ -0,0 +1,72 @@
import * as Sentry from "@sentry/nextjs";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import ErrorBoundary from "./error";
vi.mock("@/modules/ui/components/button", () => ({
Button: (props: any) => <button {...props}>{props.children}</button>,
}));
vi.mock("@/modules/ui/components/error-component", () => ({
ErrorComponent: () => <div data-testid="ErrorComponent">ErrorComponent</div>,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
describe("ErrorBoundary", () => {
afterEach(() => {
cleanup();
});
const dummyError = new Error("Test error");
const resetMock = vi.fn();
test("logs error via console.error in development", async () => {
(process.env as { [key: string]: string }).NODE_ENV = "development";
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith("Test error");
});
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("captures error with Sentry in production", async () => {
(process.env as { [key: string]: string }).NODE_ENV = "production";
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
await waitFor(() => {
expect(Sentry.captureException).toHaveBeenCalled();
});
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
test("calls reset when try again button is clicked", async () => {
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
userEvent.click(tryAgainBtn);
await waitFor(() => expect(resetMock).toHaveBeenCalled());
});
test("sets window.location.href to '/' when dashboard button is clicked", async () => {
const originalLocation = window.location;
delete (window as any).location;
(window as any).location = { href: "" };
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const dashBtn = screen.getByRole("button", { name: "common.go_to_dashboard" });
userEvent.click(dashBtn);
await waitFor(() => {
expect(window.location.href).toBe("/");
});
window.location = originalLocation;
});
});

View File

@@ -3,12 +3,15 @@
// Error components must be Client components
import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
import * as Sentry from "@sentry/nextjs";
import { useTranslate } from "@tolgee/react";
const Error = ({ error, reset }: { error: Error; reset: () => void }) => {
const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => {
const { t } = useTranslate();
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else {
Sentry.captureException(error);
}
return (
@@ -24,4 +27,4 @@ const Error = ({ error, reset }: { error: Error; reset: () => void }) => {
);
};
export default Error;
export default ErrorBoundary;

View File

@@ -0,0 +1,41 @@
import * as Sentry from "@sentry/nextjs";
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import GlobalError from "./global-error";
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
describe("GlobalError", () => {
const dummyError = new Error("Test error");
afterEach(() => {
cleanup();
});
test("logs error using console.error in development", async () => {
(process.env as { [key: string]: string }).NODE_ENV = "development";
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
render(<GlobalError error={dummyError} />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith("Test error");
});
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("captures error with Sentry in production", async () => {
(process.env as { [key: string]: string }).NODE_ENV = "production";
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(<GlobalError error={dummyError} />);
await waitFor(() => {
expect(Sentry.captureException).toHaveBeenCalled();
});
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,22 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else {
Sentry.captureException(error);
}
}, [error]);
return (
<html>
<body>
<NextError statusCode={0} />
</body>
</html>
);
}

View File

@@ -5,7 +5,7 @@ import { getTolgee } from "@/tolgee/server";
import { TolgeeStaticData } from "@tolgee/react";
import { Metadata } from "next";
import React from "react";
import { SENTRY_DSN } from "@formbricks/lib/constants";
import { IS_PRODUCTION, SENTRY_DSN } from "@formbricks/lib/constants";
import "../modules/ui/globals.css";
export const metadata: Metadata = {
@@ -25,7 +25,7 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
<SentryProvider sentryDsn={SENTRY_DSN}>
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>

View File

@@ -17,17 +17,18 @@ vi.mock("@sentry/nextjs", async () => {
};
});
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
describe("SentryProvider", () => {
afterEach(() => {
cleanup();
});
test("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(
<SentryProvider sentryDsn={sentryDsn}>
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
@@ -59,22 +60,32 @@ describe("SentryProvider", () => {
expect(initSpy).not.toHaveBeenCalled();
});
test("renders children", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
test("does not call Sentry.init when isEnabled is not provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
render(
<SentryProvider sentryDsn={sentryDsn}>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).not.toHaveBeenCalled();
});
test("renders children", () => {
render(
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});
test("processes beforeSend correctly", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
render(
<SentryProvider sentryDsn={sentryDsn}>
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);

View File

@@ -6,11 +6,12 @@ import { useEffect } from "react";
interface SentryProviderProps {
children: React.ReactNode;
sentryDsn?: string;
isEnabled?: boolean;
}
export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) => {
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => {
useEffect(() => {
if (sentryDsn) {
if (sentryDsn && isEnabled) {
Sentry.init({
dsn: sentryDsn,

View File

@@ -1,14 +1,17 @@
import { PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants";
import * as Sentry from "@sentry/nextjs";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants";
export const onRequestError = Sentry.captureRequestError;
// instrumentation.ts
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs" && PROMETHEUS_ENABLED) {
await import("./instrumentation-node");
}
if (process.env.NEXT_RUNTIME === "nodejs" && SENTRY_DSN) {
if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge" && SENTRY_DSN) {
if (process.env.NEXT_RUNTIME === "edge" && IS_PRODUCTION && SENTRY_DSN) {
await import("./sentry.edge.config");
}
};

View File

@@ -1,4 +1,5 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
@@ -9,6 +10,16 @@ const mockRequest = new Request("http://localhost");
// Add the request id header
mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
// Mock SENTRY_DSN constant
vi.mock("@formbricks/lib/constants", () => ({
SENTRY_DSN: "mocked-sentry-dsn",
IS_PRODUCTION: true,
}));
describe("utils", () => {
describe("handleApiError", () => {
test('return bad request response for "bad_request" error', async () => {
@@ -257,5 +268,45 @@ describe("utils", () => {
// Restore the original method
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
error,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API Error Details");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
});
});

View File

@@ -1,6 +1,10 @@
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
// if we don't add this we get build errors with prisma due to type-nesting
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { ZodCustomIssue, ZodIssue } from "zod";
import { IS_PRODUCTION, SENTRY_DSN } from "@formbricks/lib/constants";
import { logger } from "@formbricks/logger";
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
@@ -59,7 +63,6 @@ export const logApiRequest = (request: Request, responseStatus: number): void =>
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
);
// Info: Conveys general, operational messages about system progress and state.
logger
.withContext({
method,
@@ -73,7 +76,22 @@ export const logApiRequest = (request: Request, responseStatus: number): void =>
};
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") || "";
const correlationId = request.headers.get("x-request-id") ?? "";
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
});
}
logger
.withContext({
correlationId,

View File

@@ -350,8 +350,13 @@ export const AddApiKeyModal = ({
</div>
</div>
<div className="space-y-2">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>

View File

@@ -300,36 +300,22 @@ nextConfig.images.remotePatterns.push({
});
const sentryOptions = {
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
// Suppresses source map uploading logs during build
silent: true,
org: "formbricks",
project: "formbricks-cloud",
org: "formbricks",
project: "formbricks-cloud",
// Only print logs for uploading source maps in CI
silent: true,
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
};
const sentryConfig = {
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
const exportConfig = (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Transpiles SDK to be compatible with IE11 (increases bundle size)
transpileClientSDK: true,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
};
const exportConfig = process.env.SENTRY_DSN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
export default nextConfig;
export default exportConfig;

View File

@@ -4,9 +4,10 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { SENTRY_DSN } from "@formbricks/lib/constants";
import { logger } from "@formbricks/logger";
if (SENTRY_DSN) {
console.log("Sentry DSN found, enabling Sentry on the edge");
logger.info("Sentry DSN found, enabling Sentry on the edge");
Sentry.init({
dsn: SENTRY_DSN,
@@ -18,5 +19,5 @@ if (SENTRY_DSN) {
debug: false,
});
} else {
console.warn("Sentry DSN not found, Sentry will be disabled on the edge");
logger.warn("Sentry DSN not found, Sentry will be disabled on the edge");
}

View File

@@ -3,9 +3,10 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { SENTRY_DSN } from "@formbricks/lib/constants";
import { logger } from "@formbricks/logger";
if (SENTRY_DSN) {
console.log("Sentry DSN found, enabling Sentry on the server");
logger.info("Sentry DSN found, enabling Sentry on the server");
Sentry.init({
dsn: SENTRY_DSN,
@@ -31,5 +32,5 @@ if (SENTRY_DSN) {
},
});
} else {
console.warn("Sentry DSN not found, Sentry will be disabled on the server");
logger.warn("Sentry DSN not found, Sentry will be disabled on the server");
}

View File

@@ -12,6 +12,7 @@
"name": "next"
}
],
"skipLibCheck": true,
"strictNullChecks": true
},
"exclude": ["../../.env", "node_modules"],

View File

@@ -67,6 +67,8 @@ export default defineConfig({
"modules/ee/contacts/segments/components/segment-settings.tsx",
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
"modules/ee/sso/components/**/*.tsx",
"app/global-error.tsx",
"app/error.tsx",
"modules/account/**/*.tsx",
"modules/account/**/*.ts",
"modules/analysis/**/*.tsx",

View File

@@ -79,8 +79,14 @@ x-environment: &environment
# SURVEY_URL:
# Configure Formbricks usage within Formbricks.
#FORMBRICKS_API_HOST:
#FORMBRICKS_ENVIRONMENT_ID:
# FORMBRICKS_API_HOST:
# FORMBRICKS_ENVIRONMENT_ID:
# The SENTRY_DSN is used for error tracking and performance monitoring with Sentry.
# SENTRY_DSN:
# It's used for authentication when uploading source maps to Sentry, to make errors more readable.
# SENTRY_AUTH_TOKEN:
################################################### OPTIONAL (STORAGE) ###################################################

View File

@@ -65,6 +65,8 @@ These variables are present inside your machines docker-compose file. Restart
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
| SURVEY_URL | Set this to change the domain of the survey. | optional | WEBAPP_URL |
| SURVEY_URL | Set this to change the domain of the survey. | optional | WEBAPP_URL
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional |
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and well try our best to work out a solution with you.

View File

@@ -789,6 +789,7 @@
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
"no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden",
"organization_access": "Organisationszugang",
"organization_access_description": "Wähle Lese- oder Schreibrechte für organisationsweite Ressourcen aus.",
"permissions": "Berechtigungen",
"project_access": "Projektzugriff",
"secret": "Geheimnis",

View File

@@ -789,6 +789,7 @@
"no_api_keys_yet": "You don't have any API keys yet",
"no_env_permissions_found": "No environment permissions found",
"organization_access": "Organization Access",
"organization_access_description": "Select read or write privileges for organization-wide resources.",
"permissions": "Permissions",
"project_access": "Project Access",
"secret": "Secret",

View File

@@ -789,6 +789,7 @@
"no_api_keys_yet": "Vous n'avez pas encore de clés API.",
"no_env_permissions_found": "Aucune autorisation d'environnement trouvée",
"organization_access": "Accès à l'organisation",
"organization_access_description": "Sélectionnez les privilèges de lecture ou d'écriture pour les ressources de l'organisation.",
"permissions": "Permissions",
"project_access": "Accès au projet",
"secret": "Secret",

View File

@@ -789,6 +789,7 @@
"no_api_keys_yet": "Você ainda não tem nenhuma chave de API",
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
"organization_access": "Acesso à Organização",
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
"permissions": "Permissões",
"project_access": "Acesso ao Projeto",
"secret": "Segredo",

View File

@@ -789,6 +789,7 @@
"no_api_keys_yet": "Ainda não tem nenhuma chave API",
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
"organization_access": "Acesso à Organização",
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
"permissions": "Permissões",
"project_access": "Acesso ao Projeto",
"secret": "Segredo",

View File

@@ -789,6 +789,7 @@
"no_api_keys_yet": "您還沒有任何 API 金鑰",
"no_env_permissions_found": "找不到環境權限",
"organization_access": "組織 Access",
"organization_access_description": "選擇組織範圍資源的讀取或寫入權限。",
"permissions": "權限",
"project_access": "專案存取",
"secret": "密碼",

View File

@@ -10,6 +10,7 @@
"name": "next"
}
],
"skipLibCheck": true,
"strictNullChecks": true
},
"exclude": ["dist", "build", "node_modules", "../../packages/types/surveys.d.ts"],

2
pnpm-lock.yaml generated
View File

@@ -21319,7 +21319,7 @@ snapshots:
dependencies:
ansi-align: 3.0.1
camelcase: 7.0.1
chalk: 5.0.1
chalk: 5.4.1
cli-boxes: 3.0.0
string-width: 5.1.2
type-fest: 2.19.0