feat: add sentry sourcemaps to pre-releases (#6242)

This commit is contained in:
Victor Hugo dos Santos
2025-07-17 23:11:28 +07:00
committed by GitHub
parent 23d38b4c5b
commit d44aa17814
15 changed files with 262 additions and 64 deletions

View File

@@ -11,6 +11,10 @@ inputs:
sentry_auth_token:
description: 'Sentry authentication token'
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
required: false
default: 'staging'
runs:
using: 'composite'
@@ -107,7 +111,7 @@ runs:
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: production
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'

View File

@@ -54,3 +54,4 @@ jobs:
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 }}
environment: production

View File

@@ -29,6 +29,10 @@ jobs:
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -38,6 +42,56 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Generate SemVer version from branch or tag
id: generate_version
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
echo "Using SemVer tag: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_TAG"
echo "Using tag as prerelease: $VERSION"
fi
else
# Running from branch, use branch name as prerelease
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_BRANCH"
echo "Using branch as prerelease: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
env:
VERSION: ${{ env.VERSION }}
run: |
cd ./apps/web
npm version $VERSION --no-git-tag-version
echo "Updated version to: $(npm pkg get version)"
- name: Set Sentry environment in .env
run: |
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
echo "SENTRY_ENVIRONMENT=staging" >> .env
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
else
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
fi
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -83,6 +137,21 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Extract image info for sourcemap upload
id: extract_image_info
run: |
# Use the first readable tag from metadata action output
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
# Use the generated version for Sentry release
RELEASE_VERSION="$VERSION"
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Docker image: $DOCKER_IMAGE"
echo "Release version: $RELEASE_VERSION"
echo "Available tags: ${{ steps.meta.outputs.tags }}"
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
@@ -97,3 +166,25 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- build
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: ${{ needs.build.outputs.DOCKER_IMAGE }}
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: staging

View File

@@ -55,6 +55,11 @@ jobs:
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set Sentry environment in .env
run: |
echo "SENTRY_ENVIRONMENT=production" >> .env
echo "Set SENTRY_ENVIRONMENT=production in .env file"
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0

View File

@@ -9,13 +9,34 @@ vi.mock("@/modules/ui/components/button", () => ({
}));
vi.mock("@/modules/ui/components/error-component", () => ({
ErrorComponent: () => <div data-testid="ErrorComponent">ErrorComponent</div>,
ErrorComponent: ({ title, description }: { title: string; description: string }) => (
<div data-testid="ErrorComponent">
<div data-testid="error-title">{title}</div>
<div data-testid="error-description">{description}</div>
</div>
),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"common.error_rate_limit_title": "Too Many Requests",
"common.error_rate_limit_description": "You're making too many requests. Please slow down.",
"common.error_component_title": "Something went wrong",
"common.error_component_description": "An unexpected error occurred. Please try again.",
"common.try_again": "Try Again",
"common.go_to_dashboard": "Go to Dashboard",
};
return translations[key] || key;
},
}),
}));
vi.mock("@formbricks/types/errors", async (importOriginal) => {
const actual = await importOriginal<typeof import("@formbricks/types/errors")>();
return {
@@ -40,8 +61,7 @@ describe("ErrorBoundary", () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
type: "general",
showButtons: true,
});
@@ -60,8 +80,7 @@ describe("ErrorBoundary", () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
type: "general",
showButtons: true,
});
@@ -76,13 +95,12 @@ describe("ErrorBoundary", () => {
test("calls reset when try again button is clicked for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
const tryAgainBtn = screen.getByRole("button", { name: "Try Again" });
userEvent.click(tryAgainBtn);
await waitFor(() => expect(resetMock).toHaveBeenCalled());
});
@@ -90,16 +108,15 @@ describe("ErrorBoundary", () => {
test("sets window.location.href to '/' when dashboard button is clicked for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
type: "general",
showButtons: true,
});
const originalLocation = window.location;
delete (window as any).location;
(window as any).location = undefined;
(window as any).location = { href: "" };
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const dashBtn = screen.getByRole("button", { name: "common.go_to_dashboard" });
const dashBtn = screen.getByRole("button", { name: "Go to Dashboard" });
userEvent.click(dashBtn);
await waitFor(() => {
expect(window.location.href).toBe("/");
@@ -110,28 +127,60 @@ describe("ErrorBoundary", () => {
test("does not show buttons for rate limit errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "common.error_rate_limit_title",
description: "common.error_rate_limit_description",
type: "rate_limit",
showButtons: false,
});
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
expect(screen.queryByRole("button", { name: "common.try_again" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.go_to_dashboard" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Try Again" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Go to Dashboard" })).not.toBeInTheDocument();
});
test("shows error component with custom title and description for rate limit errors", async () => {
test("shows error component with rate limit messages for rate limit errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "common.error_rate_limit_title",
description: "common.error_rate_limit_description",
type: "rate_limit",
showButtons: false,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
expect(screen.getByTestId("error-title")).toHaveTextContent("Too Many Requests");
expect(screen.getByTestId("error-description")).toHaveTextContent(
"You're making too many requests. Please slow down."
);
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
});
test("shows error component with general messages for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
expect(screen.getByTestId("error-description")).toHaveTextContent(
"An unexpected error occurred. Please try again."
);
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
});
test("shows buttons for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
type: "general",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
expect(screen.getByRole("button", { name: "Try Again" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeInTheDocument();
});
});

View File

@@ -5,11 +5,30 @@ 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";
import { getClientErrorData } from "@formbricks/types/errors";
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
/**
* Get translated error messages based on error type
* All translation keys are directly visible to Tolgee's static analysis
*/
const getErrorMessages = (type: ClientErrorType, t: (key: string) => string) => {
if (type === "rate_limit") {
return {
title: t("common.error_rate_limit_title"),
description: t("common.error_rate_limit_description"),
};
}
return {
title: t("common.error_component_title"),
description: t("common.error_component_description"),
};
};
const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => {
const { t } = useTranslate();
const errorData = getClientErrorData(error);
const { title, description } = getErrorMessages(errorData.type, t);
if (process.env.NODE_ENV === "development") {
console.error(error.message);
@@ -19,7 +38,7 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
return (
<div className="flex h-full w-full flex-col items-center justify-center">
<ErrorComponent title={errorData.title} description={errorData.description} />
<ErrorComponent title={title} description={description} />
{errorData.showButtons && (
<div className="mt-2">
<Button variant="secondary" onClick={() => reset()} className="mr-2">

View File

@@ -199,10 +199,6 @@
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern",
"finish": "Fertigstellen",
"follow_these": "Folge diesen",

View File

@@ -199,10 +199,6 @@
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
"error": "Error",
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
"error_component_title": "Error loading resources",
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows",
"finish": "Finish",
"follow_these": "Follow these",

View File

@@ -199,10 +199,6 @@
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
"error_component_title": "Erreur de chargement des ressources",
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
"finish": "Terminer",
"follow_these": "Suivez ceci",

View File

@@ -199,10 +199,6 @@
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
"error_component_title": "Erro ao carregar recursos",
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"finish": "Terminar",
"follow_these": "Siga esses",

View File

@@ -199,10 +199,6 @@
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
"error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
"error_component_title": "Erro ao carregar recursos",
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"finish": "Concluir",
"follow_these": "Siga estes",

View File

@@ -199,10 +199,6 @@
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
"error_component_title": "載入資源錯誤",
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
"finish": "完成",
"follow_these": "按照這些步驟",

View File

@@ -1,20 +1,59 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ErrorComponent } from "./index";
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"common.error_component_title": "Something went wrong",
"common.error_component_description": "An unexpected error occurred. Please try again.",
};
return translations[key] || key;
},
}),
}));
describe("ErrorComponent", () => {
afterEach(() => {
cleanup();
});
test("renders error title", () => {
test("renders with default translations when no props provided", () => {
render(<ErrorComponent />);
expect(screen.getByTestId("error-title")).toBeInTheDocument();
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
expect(screen.getByTestId("error-description")).toHaveTextContent(
"An unexpected error occurred. Please try again."
);
});
test("renders error description", () => {
render(<ErrorComponent />);
expect(screen.getByTestId("error-description")).toBeInTheDocument();
test("renders with custom title when provided", () => {
const customTitle = "Custom Error Title";
render(<ErrorComponent title={customTitle} />);
expect(screen.getByTestId("error-title")).toHaveTextContent(customTitle);
expect(screen.getByTestId("error-description")).toHaveTextContent(
"An unexpected error occurred. Please try again."
);
});
test("renders with custom description when provided", () => {
const customDescription = "Custom error description";
render(<ErrorComponent description={customDescription} />);
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
expect(screen.getByTestId("error-description")).toHaveTextContent(customDescription);
});
test("renders with both custom title and description when provided", () => {
const customTitle = "Custom Error Title";
const customDescription = "Custom error description";
render(<ErrorComponent title={customTitle} description={customDescription} />);
expect(screen.getByTestId("error-title")).toHaveTextContent(customTitle);
expect(screen.getByTestId("error-description")).toHaveTextContent(customDescription);
});
test("renders error icon", () => {
@@ -23,4 +62,16 @@ describe("ErrorComponent", () => {
const iconElement = document.querySelector("[aria-hidden='true']");
expect(iconElement).toBeInTheDocument();
});
test("uses fallback translation when title is empty string", () => {
render(<ErrorComponent title="" />);
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
});
test("uses fallback translation when description is empty string", () => {
render(<ErrorComponent description="" />);
expect(screen.getByTestId("error-description")).toHaveTextContent(
"An unexpected error occurred. Please try again."
);
});
});

View File

@@ -4,17 +4,15 @@ import { useTranslate } from "@tolgee/react";
import { XCircleIcon } from "lucide-react";
interface ErrorComponentProps {
/** Pre-translated title text. If not provided, uses default error title */
title?: string;
/** Pre-translated description text. If not provided, uses default error description */
description?: string;
}
export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, description }) => {
const { t } = useTranslate();
// Use custom title/description if provided, otherwise fallback to translations
const errorTitle = title || "common.error_component_title";
const errorDescription = description || "common.error_component_description";
return (
<div className="rounded-lg bg-red-50 p-4">
<div className="flex">
@@ -23,10 +21,10 @@ export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, descripti
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800" data-testid="error-title">
{t(errorTitle)}
{title || t("common.error_component_title")}
</h3>
<div className="mt-2 text-sm text-red-700" data-testid="error-description">
<p>{t(errorDescription)}</p>
<p>{description || t("common.error_component_description")}</p>
</div>
</div>
</div>

View File

@@ -147,9 +147,15 @@ export interface ApiErrorResponse {
responseMessage?: string;
}
/**
* Error types for UI display
*/
export type ClientErrorType = "rate_limit" | "general";
export interface ClientErrorData {
title: string;
description: string;
/** Error type to determine which translations to use */
type: ClientErrorType;
/** Whether to show action buttons */
showButtons?: boolean;
}
@@ -160,16 +166,14 @@ export const getClientErrorData = (error: Error): ClientErrorData => {
// Check by error name as fallback (in case instanceof fails due to module loading issues)
if (error.name === "TooManyRequestsError") {
return {
title: "common.error_rate_limit_title",
description: "common.error_rate_limit_description",
type: "rate_limit",
showButtons: false,
};
}
// Default to general error for any other error
return {
title: "common.error_component_title",
description: "common.error_component_description",
type: "general",
showButtons: true,
};
};