diff --git a/apps/web/app/lib/api/with-api-logging.test.ts b/apps/web/app/lib/api/with-api-logging.test.ts index 3eed910d58..f589452c80 100644 --- a/apps/web/app/lib/api/with-api-logging.test.ts +++ b/apps/web/app/lib/api/with-api-logging.test.ts @@ -1,9 +1,9 @@ -import { AuthenticationMethod } from "@/app/middleware/endpoint-validator"; import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { Mock, beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "@formbricks/logger"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { AuthenticationMethod } from "@/app/middleware/endpoint-validator"; import { responses } from "./response"; // Mocks @@ -14,6 +14,10 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn(), + withScope: vi.fn((callback) => { + callback(mockSentryScope); + return mockSentryScope; + }), })); // Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks. @@ -21,6 +25,14 @@ const mockContextualLoggerError = vi.fn(); const mockContextualLoggerWarn = vi.fn(); const mockContextualLoggerInfo = vi.fn(); +// Mock Sentry scope that can be referenced in tests +const mockSentryScope = { + setTag: vi.fn(), + setExtra: vi.fn(), + setContext: vi.fn(), + setLevel: vi.fn(), +}; + vi.mock("@formbricks/logger", () => { const mockWithContextInstance = vi.fn(() => ({ error: mockContextualLoggerError, @@ -110,6 +122,12 @@ describe("withV1ApiWrapper", () => { })); vi.clearAllMocks(); + + // Reset mock Sentry scope calls + mockSentryScope.setTag.mockClear(); + mockSentryScope.setExtra.mockClear(); + mockSentryScope.setContext.mockClear(); + mockSentryScope.setLevel.mockClear(); }); test("logs and audits on error response with API key authentication", async () => { @@ -161,10 +179,9 @@ describe("withV1ApiWrapper", () => { organizationId: "org-1", }) ); - expect(Sentry.captureException).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) }) - ); + expect(Sentry.withScope).toHaveBeenCalled(); + expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); }); test("does not log Sentry if not 500", async () => { @@ -269,10 +286,8 @@ describe("withV1ApiWrapper", () => { organizationId: "org-1", }) ); - expect(Sentry.captureException).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) }) - ); + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); }); test("does not log on success response but still audits", async () => { diff --git a/apps/web/app/lib/api/with-api-logging.ts b/apps/web/app/lib/api/with-api-logging.ts index 3a07fe6051..6c2c18e771 100644 --- a/apps/web/app/lib/api/with-api-logging.ts +++ b/apps/web/app/lib/api/with-api-logging.ts @@ -1,3 +1,8 @@ +import * as Sentry from "@sentry/nextjs"; +import { Session, getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { logger } from "@formbricks/logger"; +import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { @@ -14,11 +19,6 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit"; import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; -import * as Sentry from "@sentry/nextjs"; -import { Session, getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; -import { logger } from "@formbricks/logger"; -import { TAuthenticationApiKey } from "@formbricks/types/auth"; export type TApiAuditLog = Parameters[0]; export type TApiV1Authentication = TAuthenticationApiKey | Session | null; @@ -173,8 +173,21 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, logger.withContext(logContext).error("V1 API Error Details"); if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) { - const err = new Error(`API V1 error, id: ${correlationId}`); - Sentry.captureException(err, { extra: { error, correlationId } }); + // Set correlation ID as a tag for easy filtering + Sentry.withScope((scope) => { + scope.setTag("correlationId", correlationId); + scope.setLevel("error"); + + // If we have an actual error, capture it with full stacktrace + // Otherwise, create a generic error with context + if (error instanceof Error) { + Sentry.captureException(error); + } else { + scope.setExtra("originalError", error); + const genericError = new Error(`API V1 error, id: ${correlationId}`); + Sentry.captureException(genericError); + } + }); } }; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 86001caa5d..dd7f0c1848 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -264,7 +264,6 @@ "minimum": "Minimum", "mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.", "mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!", - "mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.", "mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!", "move_down": "Nach unten bewegen", "move_up": "Nach oben bewegen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 28354d0144..8a94221dc7 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -264,7 +264,6 @@ "minimum": "Minimum", "mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.", "mobile_overlay_surveys_look_good": "Don't worry – your surveys look great on every device and screen size!", - "mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.", "mobile_overlay_title": "Oops, tiny screen detected!", "move_down": "Move down", "move_up": "Move up", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 11dc23f831..e8f5b045e0 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -264,7 +264,6 @@ "minimum": "Min", "mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.", "mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!", - "mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.", "mobile_overlay_title": "Oups, écran minuscule détecté!", "move_down": "Déplacer vers le bas", "move_up": "Déplacer vers le haut", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 0ba2e0b0cc..ab61fb2ef8 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -264,7 +264,6 @@ "minimum": "最小", "mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。", "mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!", - "mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。", "mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!", "move_down": "下に移動", "move_up": "上に移動", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 3e343471b6..00cc4facea 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -264,7 +264,6 @@ "minimum": "Mínimo", "mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.", "mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!", - "mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.", "mobile_overlay_title": "Eita, tela pequena detectada!", "move_down": "Descer", "move_up": "Subir", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 3d2209fc7e..eba76d7364 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -264,7 +264,6 @@ "minimum": "Mínimo", "mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.", "mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!", - "mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.", "mobile_overlay_title": "Oops, ecrã pequeno detectado!", "move_down": "Mover para baixo", "move_up": "Mover para cima", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 8e07ca5914..ebe96c66a0 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -264,7 +264,6 @@ "minimum": "Minim", "mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.", "mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!", - "mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.", "mobile_overlay_title": "Ups, ecran mic detectat!", "move_down": "Mută în jos", "move_up": "Mută sus", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 29430f6e84..24a5ee53c6 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -264,7 +264,6 @@ "minimum": "最低", "mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。", "mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!", - "mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备", "mobile_overlay_title": "噢, 检测 到 小 屏幕!", "move_down": "下移", "move_up": "上移", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 63037c24a1..2fa24a0c54 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -264,7 +264,6 @@ "minimum": "最小值", "mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。", "mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!", - "mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。", "mobile_overlay_title": "糟糕 ,偵測到小螢幕!", "move_down": "下移", "move_up": "上移", diff --git a/apps/web/modules/api/v2/lib/tests/utils.test.ts b/apps/web/modules/api/v2/lib/tests/utils.test.ts index 36f5c5c6bb..0cd84cbc97 100644 --- a/apps/web/modules/api/v2/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/lib/tests/utils.test.ts @@ -1,8 +1,8 @@ -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"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils"; const mockRequest = new Request("http://localhost"); @@ -12,6 +12,15 @@ mockRequest.headers.set("x-request-id", "123"); vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn(), + withScope: vi.fn((callback: (scope: any) => void) => { + const mockScope = { + setTag: vi.fn(), + setContext: vi.fn(), + setLevel: vi.fn(), + setExtra: vi.fn(), + }; + callback(mockScope); + }), })); // Mock SENTRY_DSN constant @@ -232,7 +241,7 @@ describe("utils", () => { }); // Verify error was called on the child logger - expect(errorMock).toHaveBeenCalledWith("API Error Details"); + expect(errorMock).toHaveBeenCalledWith("API V2 Error Details"); // Restore the original method logger.withContext = originalWithContext; @@ -266,7 +275,7 @@ describe("utils", () => { }); // Verify error was called on the child logger - expect(errorMock).toHaveBeenCalledWith("API Error Details"); + expect(errorMock).toHaveBeenCalledWith("API V2 Error Details"); // Restore the original method logger.withContext = originalWithContext; @@ -303,7 +312,7 @@ describe("utils", () => { }); // Verify error was called on the child logger - expect(errorMock).toHaveBeenCalledWith("API Error Details"); + expect(errorMock).toHaveBeenCalledWith("API V2 Error Details"); // Verify Sentry.captureException was called expect(Sentry.captureException).toHaveBeenCalled(); diff --git a/apps/web/modules/api/v2/lib/utils-edge.ts b/apps/web/modules/api/v2/lib/utils-edge.ts index 0d748f34db..d7d6775949 100644 --- a/apps/web/modules/api/v2/lib/utils-edge.ts +++ b/apps/web/modules/api/v2/lib/utils-edge.ts @@ -1,8 +1,8 @@ // Function is this file can be used in edge runtime functions, like api routes. -import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import * as Sentry from "@sentry/nextjs"; import { logger } from "@formbricks/logger"; +import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => { const correlationId = request.headers.get("x-request-id") ?? ""; @@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo // 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}`); + // Use Sentry scope to add correlation ID as a tag for easy filtering + Sentry.withScope((scope) => { + scope.setTag("correlationId", correlationId); + scope.setLevel("error"); - Sentry.captureException(err, { - extra: { - details: error.details, - type: error.type, - correlationId, - }, + scope.setExtra("originalError", error); + const err = new Error(`API V2 error, id: ${correlationId}`); + Sentry.captureException(err); }); } @@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo correlationId, error, }) - .error("API Error Details"); + .error("API V2 Error Details"); }; diff --git a/apps/web/modules/api/v2/lib/utils.ts b/apps/web/modules/api/v2/lib/utils.ts index c0ac31ffd3..faf9730b19 100644 --- a/apps/web/modules/api/v2/lib/utils.ts +++ b/apps/web/modules/api/v2/lib/utils.ts @@ -1,17 +1,18 @@ // @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 { ZodCustomIssue, ZodIssue } from "zod"; +import { logger } from "@formbricks/logger"; +import { TApiAuditLog } from "@/app/lib/api/with-api-logging"; import { AUDIT_LOG_ENABLED } from "@/lib/constants"; import { responses } from "@/modules/api/v2/lib/response"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; -import { ZodCustomIssue, ZodIssue } from "zod"; -import { logger } from "@formbricks/logger"; import { logApiErrorEdge } from "./utils-edge"; export const handleApiError = ( request: Request, err: ApiErrorResponseV2, - auditLog?: ApiAuditLog + auditLog?: TApiAuditLog ): Response => { logApiError(request, err, auditLog); @@ -55,7 +56,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] }) }); }; -export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => { +export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => { const method = request.method; const url = new URL(request.url); const path = url.pathname; @@ -82,13 +83,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog logAuditLog(request, auditLog); }; -export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => { +export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => { logApiErrorEdge(request, error); logAuditLog(request, auditLog); }; -const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => { +const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => { if (AUDIT_LOG_ENABLED && auditLog) { const correlationId = request.headers.get("x-request-id") ?? ""; queueAuditEvent({