diff --git a/.env.example b/.env.example index 664abd3bf7..b3ed82c802 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,8 @@ UNKEY_ROOT_KEY= # 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= +# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard +# SENTRY_ENVIRONMENT= # Configure the minimum role for user management from UI(owner, manager, disabled) # USER_MANAGEMENT_MINIMUM_ROLE="manager" diff --git a/.github/actions/upload-sentry-sourcemaps/action.yml b/.github/actions/upload-sentry-sourcemaps/action.yml new file mode 100644 index 0000000000..e8510aa2f2 --- /dev/null +++ b/.github/actions/upload-sentry-sourcemaps/action.yml @@ -0,0 +1,121 @@ +name: 'Upload Sentry Sourcemaps' +description: 'Extract sourcemaps from Docker image and upload to Sentry' + +inputs: + docker_image: + description: 'Docker image to extract sourcemaps from' + required: true + release_version: + description: 'Sentry release version (e.g., v1.2.3)' + required: true + sentry_auth_token: + description: 'Sentry authentication token' + required: true + +runs: + using: 'composite' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate Sentry auth token + shell: bash + run: | + set -euo pipefail + echo "๐Ÿ” Validating Sentry authentication token..." + + # Assign token to local variable for secure handling + SENTRY_TOKEN="${{ inputs.sentry_auth_token }}" + + # Test the token by making a simple API call to Sentry + response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \ + -H "Authorization: Bearer $SENTRY_TOKEN" \ + "https://sentry.io/api/0/organizations/formbricks/") + + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" != "200" ]; then + echo "โŒ Error: Invalid Sentry auth token (HTTP $http_code)" + echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions." + if [ -f /tmp/sentry_response.json ]; then + echo "Response body:" + cat /tmp/sentry_response.json + fi + exit 1 + fi + + echo "โœ… Sentry auth token validated successfully" + + # Clean up temp file + rm -f /tmp/sentry_response.json + + - name: Extract sourcemaps from Docker image + shell: bash + run: | + set -euo pipefail + echo "๐Ÿ“ฆ Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}" + + # Create temporary container from the image and capture its ID + echo "Creating temporary container..." + CONTAINER_ID=$(docker create "${{ inputs.docker_image }}") + echo "Container created with ID: $CONTAINER_ID" + + # Set up cleanup function to ensure container is removed on script exit + cleanup_container() { + # Capture the current exit code to preserve it + local original_exit_code=$? + + echo "๐Ÿงน Cleaning up Docker container..." + + # Remove the container if it exists (ignore errors if already removed) + if [ -n "$CONTAINER_ID" ]; then + docker rm -f "$CONTAINER_ID" 2>/dev/null || true + echo "Container $CONTAINER_ID removed" + fi + + # Exit with the original exit code to preserve script success/failure status + exit $original_exit_code + } + + # Register cleanup function to run on script exit (success or failure) + trap cleanup_container EXIT + + # Extract .next directory containing sourcemaps + docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next + + # Verify sourcemaps exist + if [ ! -d "./extracted-next/static/chunks" ]; then + echo "โŒ Error: .next/static/chunks directory not found in Docker image" + echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/" + exit 1 + fi + + sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l) + echo "โœ… Found $sourcemap_count sourcemap files" + + if [ "$sourcemap_count" -eq 0 ]; then + echo "โŒ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled." + exit 1 + fi + + - name: Create Sentry release and upload sourcemaps + uses: getsentry/action-release@v3 + env: + SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }} + SENTRY_ORG: formbricks + SENTRY_PROJECT: formbricks-cloud + with: + environment: production + version: ${{ inputs.release_version }} + sourcemaps: './extracted-next/' + + - name: Clean up extracted files + shell: bash + if: always() + run: | + set -euo pipefail + # Clean up extracted files + rm -rf ./extracted-next + echo "๐Ÿงน Cleaned up extracted files" diff --git a/.github/workflows/formbricks-release.yml b/.github/workflows/formbricks-release.yml index 68f45a88b5..6df33e7dd8 100644 --- a/.github/workflows/formbricks-release.yml +++ b/.github/workflows/formbricks-release.yml @@ -32,3 +32,25 @@ jobs: with: VERSION: v${{ needs.docker-build.outputs.VERSION }} ENVIRONMENT: "prod" + + upload-sentry-sourcemaps: + name: Upload Sentry Sourcemaps + runs-on: ubuntu-latest + permissions: + contents: read + needs: + - docker-build + - deploy-formbricks-cloud + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Upload Sentry Sourcemaps + uses: ./.github/actions/upload-sentry-sourcemaps + continue-on-error: true + with: + docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }} + release_version: v${{ needs.docker-build.outputs.VERSION }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/upload-sentry-sourcemaps.yml b/.github/workflows/upload-sentry-sourcemaps.yml new file mode 100644 index 0000000000..7af92ebc10 --- /dev/null +++ b/.github/workflows/upload-sentry-sourcemaps.yml @@ -0,0 +1,46 @@ +name: Upload Sentry Sourcemaps (Manual) + +on: + workflow_dispatch: + inputs: + docker_image: + description: "Docker image to extract sourcemaps from" + required: true + type: string + release_version: + description: "Release version (e.g., v1.2.3)" + required: true + type: string + tag_version: + description: "Docker image tag (leave empty to use release_version)" + required: false + type: string + +permissions: + contents: read + +jobs: + upload-sourcemaps: + name: Upload Sourcemaps to Sentry + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + + - name: Set Docker Image + run: | + if [ -n "${{ inputs.tag_version }}" ]; then + echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV + else + echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV + fi + + - name: Upload Sourcemaps to Sentry + uses: ./.github/actions/upload-sentry-sourcemaps + with: + docker_image: ${{ env.DOCKER_IMAGE }} + release_version: ${{ inputs.release_version }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} \ No newline at end of file diff --git a/apps/web/app/layout.test.tsx b/apps/web/app/layout.test.tsx index 1cad3293ea..4c3f4031d5 100644 --- a/apps/web/app/layout.test.tsx +++ b/apps/web/app/layout.test.tsx @@ -31,6 +31,8 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SENTRY_RELEASE: "mock-sentry-release", + SENTRY_ENVIRONMENT: "mock-sentry-environment", })); vi.mock("@/tolgee/language", () => ({ @@ -59,9 +61,18 @@ vi.mock("@/tolgee/client", () => ({ })); vi.mock("@/app/sentry/SentryProvider", () => ({ - SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => ( + SentryProvider: ({ + children, + sentryDsn, + sentryRelease, + }: { + children: React.ReactNode; + sentryDsn?: string; + sentryRelease?: string; + }) => (
SentryProvider: {sentryDsn} + {sentryRelease && ` - Release: ${sentryRelease}`} {children}
), diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index ee7b027e7c..eb6eaef0aa 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,5 @@ import { SentryProvider } from "@/app/sentry/SentryProvider"; -import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; +import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants"; import { TolgeeNextProvider } from "@/tolgee/client"; import { getLocale } from "@/tolgee/language"; import { getTolgee } from "@/tolgee/server"; @@ -25,7 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => { return ( - + {children} diff --git a/apps/web/app/sentry/SentryProvider.test.tsx b/apps/web/app/sentry/SentryProvider.test.tsx index 1756efe45a..6be78aeab0 100644 --- a/apps/web/app/sentry/SentryProvider.test.tsx +++ b/apps/web/app/sentry/SentryProvider.test.tsx @@ -48,6 +48,24 @@ describe("SentryProvider", () => { ); }); + test("calls Sentry.init with sentryRelease when provided", () => { + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + const testRelease = "v1.2.3"; + + render( + +
Test Content
+
+ ); + + expect(initSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: sentryDsn, + release: testRelease, + }) + ); + }); + test("does not call Sentry.init when sentryDsn is not provided", () => { const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); diff --git a/apps/web/app/sentry/SentryProvider.tsx b/apps/web/app/sentry/SentryProvider.tsx index d67b399135..da65b2cbf0 100644 --- a/apps/web/app/sentry/SentryProvider.tsx +++ b/apps/web/app/sentry/SentryProvider.tsx @@ -6,14 +6,24 @@ import { useEffect } from "react"; interface SentryProviderProps { children: React.ReactNode; sentryDsn?: string; + sentryRelease?: string; + sentryEnvironment?: string; isEnabled?: boolean; } -export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => { +export const SentryProvider = ({ + children, + sentryDsn, + sentryRelease, + sentryEnvironment, + isEnabled, +}: SentryProviderProps) => { useEffect(() => { if (sentryDsn && isEnabled) { Sentry.init({ dsn: sentryDsn, + release: sentryRelease, + environment: sentryEnvironment, // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737 tracesSampleRate: 0, diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index db50e9f8da..f877709ba6 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -273,6 +273,24 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY; export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY; export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY); +// Use the app version for Sentry release (updated during build in production) +// Fallback to environment variable if package.json is not accessible +export const SENTRY_RELEASE = (() => { + if (process.env.NODE_ENV !== "production") { + return undefined; + } + + // Try to read from package.json with proper error handling + try { + const pkg = require("../package.json"); + return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`; + } catch { + // If package.json can't be read (e.g., in some deployment scenarios), + // return undefined and let Sentry work without release tracking + return undefined; + } +})(); +export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT; export const SENTRY_DSN = env.SENTRY_DSN; export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 8efb312673..3c39603ab4 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -127,6 +127,7 @@ export const env = createEnv({ .string() .transform((val) => parseInt(val)) .optional(), + SENTRY_ENVIRONMENT: z.string().optional(), }, /* @@ -225,5 +226,6 @@ export const env = createEnv({ AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP, SESSION_MAX_AGE: process.env.SESSION_MAX_AGE, + SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT, }, }); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 88ba2efb5d..7821e62b4b 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -20,7 +20,7 @@ const nextConfig = { cacheMaxMemorySize: 0, // disable default in-memory caching output: "standalone", poweredByHeader: false, - productionBrowserSourceMaps: false, + productionBrowserSourceMaps: true, serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"], outputFileTracingIncludes: { "/api/auth/**/*": ["../../node_modules/jose/**/*"], @@ -425,6 +425,7 @@ const sentryOptions = { org: "formbricks", project: "formbricks-cloud", + environment: process.env.SENTRY_ENVIRONMENT, // Only print logs for uploading source maps in CI silent: true, @@ -434,11 +435,23 @@ const sentryOptions = { // Automatically tree-shake Sentry logger statements to reduce bundle size disableLogger: true, + + // Disable automatic release management + automaticVercelMonitors: false, + autoUploadSourceMaps: false, + hideSourceMaps: false, + + // Don't automatically create releases - we handle this in GitHub Actions + release: { + create: false, + deploy: false, + setCommits: false, + }, }; const exportConfig = - process.env.SENTRY_DSN && process.env.NODE_ENV === "production" - ? withSentryConfig(nextConfig, sentryOptions) - : nextConfig; + (process.env.SENTRY_DSN && process.env.NODE_ENV === "production") + ? withSentryConfig(nextConfig, sentryOptions) : + nextConfig; export default exportConfig; diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts index 576498b3e1..fabe1ea681 100644 --- a/apps/web/sentry.edge.config.ts +++ b/apps/web/sentry.edge.config.ts @@ -2,7 +2,7 @@ // 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 { SENTRY_DSN } from "@/lib/constants"; +import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants"; import * as Sentry from "@sentry/nextjs"; import { logger } from "@formbricks/logger"; @@ -11,6 +11,8 @@ if (SENTRY_DSN) { Sentry.init({ dsn: SENTRY_DSN, + release: SENTRY_RELEASE, + environment: SENTRY_ENVIRONMENT, // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737 tracesSampleRate: 0, diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts index c91683ff0a..321a9c594a 100644 --- a/apps/web/sentry.server.config.ts +++ b/apps/web/sentry.server.config.ts @@ -1,7 +1,7 @@ // 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 { SENTRY_DSN } from "@/lib/constants"; +import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants"; import * as Sentry from "@sentry/nextjs"; import { logger } from "@formbricks/logger"; @@ -10,6 +10,8 @@ if (SENTRY_DSN) { Sentry.init({ dsn: SENTRY_DSN, + release: SENTRY_RELEASE, + environment: SENTRY_ENVIRONMENT, // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737 tracesSampleRate: 0, diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b73224cd3d..8e32a42f9c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -82,6 +82,8 @@ x-environment: &environment # SENTRY_DSN: # It's used for authentication when uploading source maps to Sentry, to make errors more readable. # SENTRY_AUTH_TOKEN: + # The SENTRY_ENVIRONMENT is used to identify the environment in Sentry. + # SENTRY_ENVIRONMENT: ################################################### OPTIONAL (STORAGE) ################################################### diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx index 0308fe3ebf..0e4c227431 100644 --- a/docs/self-hosting/configuration/environment-variables.mdx +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -54,7 +54,7 @@ These variables are present inside your machine's docker-compose file. Restart t | STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | | | STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | | | TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | | -| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b | +| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b | | DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | | | OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | | | OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | | @@ -68,6 +68,7 @@ These variables are present inside your machine's docker-compose file. Restart t | DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 | | DEFAULT_TEAM_ID | Default team ID for new users. | optional | | | SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional | | +| SENTRY_ENVIRONMENT | Set this to identify the environment in Sentry | optional | | | SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional | | | SESSION_MAX_AGE | Configure the maximum age for the session in seconds. | optional | 86400 (24 hours) | | USER_MANAGEMENT_MINIMUM_ROLE | Set this to control which roles can access user management features. Accepted values: "owner", "manager", "disabled" | optional | manager | diff --git a/turbo.json b/turbo.json index b0b923df36..d4abde199a 100644 --- a/turbo.json +++ b/turbo.json @@ -146,6 +146,7 @@ "SAML_DATABASE_URL", "SESSION_MAX_AGE", "SENTRY_DSN", + "SENTRY_ENVIRONMENT", "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", "SMTP_HOST",