Compare commits

...

3 Commits

Author SHA1 Message Date
Johannes
044a657d51 cleaned up the form, added security sign up 2025-11-19 17:35:09 +01:00
Johannes
256a0ec81a attach more telemetry to license check 2025-11-18 21:09:33 +01:00
Johannes
58ab40ab8e chore: remove unused handleBillingLimitsCheck function and utils file
- Delete apps/web/app/api/lib/utils.ts as it only contained a no-op function
- Remove handleBillingLimitsCheck calls from all response creation endpoints
- Function was a placeholder with no actual implementation
2025-11-18 14:51:04 +01:00
65 changed files with 1041 additions and 810 deletions

View File

@@ -1,8 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
return (
<div className="flex-1 bg-slate-50">
<PosthogIdentify
session={session}
user={user}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient />
{children}
</div>

View File

@@ -8,7 +8,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
@@ -25,11 +25,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<EnvironmentIdBaseLayout>
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>

View File

@@ -1,61 +0,0 @@
"use client";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface PosthogIdentifyProps {
session: Session;
user: TUser;
environmentId?: string;
organizationId?: string;
organizationName?: string;
organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
}
export const PosthogIdentify = ({
session,
user,
environmentId,
organizationId,
organizationName,
organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
useEffect(() => {
if (isPosthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, {
name: user.name,
email: user.email,
});
if (environmentId) {
posthog.group("environment", environmentId, { name: environmentId });
}
if (organizationId) {
posthog.group("organization", organizationId, {
name: organizationName,
plan: organizationBilling?.plan,
responseLimit: organizationBilling?.limits.monthly.responses,
miuLimit: organizationBilling?.limits.monthly.miu,
});
}
}
}, [
posthog,
session.user,
environmentId,
organizationId,
organizationName,
organizationBilling,
user.name,
user.email,
isPosthogEnabled,
]);
return null;
};

View File

@@ -24,11 +24,7 @@ const EnvLayout = async (props: {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentIdBaseLayout>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}

View File

@@ -1,12 +1,9 @@
import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const AppLayout = async ({ children }) => {
@@ -21,20 +18,9 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<Suspense>
<PostHogPageview
posthogEnabled={IS_POSTHOG_CONFIGURED}
postHogApiHost={POSTHOG_API_HOST}
postHogApiKey={POSTHOG_API_KEY}
/>
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
</PHProvider>
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>
);
};

View File

@@ -1,34 +0,0 @@
import { Organization } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
export const handleBillingLimitsCheck = async (
environmentId: string,
organizationId: string,
organizationBilling: Organization["billing"]
): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD) return;
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
const responsesLimit = organizationBilling.limits.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organizationBilling.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
};

View File

@@ -18,10 +18,6 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
@@ -58,19 +54,7 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: { responses: monthlyResponseLimit, miu: null },
},
});
} catch (error) {
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
}
}
// Limit check completed
return isLimitReached;
};
@@ -111,10 +95,7 @@ export const GET = withV1ApiWrapper({
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
await updateEnvironment(environment.id, { appSetupCompleted: true });
}
// check organization subscriptions and response limits

View File

@@ -5,7 +5,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return {
response: responses.successResponse(response, true),
};

View File

@@ -8,16 +8,11 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
// Mock dependencies
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(),
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
}));
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
where: { id: environmentId },
data: { appSetupCompleted: true },
});
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.data).toBeDefined();
});
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: mockOrganization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: mockOrganization.billing.limits.monthly.responses,
},
},
});
});
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
@@ -256,21 +237,6 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should handle error when sending Posthog limit reached event", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("Posthog failed");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
@@ -313,7 +279,6 @@ describe("getEnvironmentState", () => {
// Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should propagate database update errors", async () => {
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
});
test("should propagate PostHog event capture errors", async () => {
const incompleteEnvironmentData = {
...mockEnvironmentStateData,
environment: {
...mockEnvironmentStateData.environment,
appSetupCompleted: false,
},
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
// Should throw error since Promise.all will fail if PostHog event capture fails
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
});
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
const result = await getEnvironmentState(environmentId);

View File

@@ -1,15 +1,10 @@
import "server-only";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getEnvironmentStateData } from "./data";
/**
@@ -33,13 +28,10 @@ export const getEnvironmentState = async (
// Handle app setup completion update if needed
// This is a one-time setup flag that can tolerate TTL-based cache expiration
if (!environment.appSetupCompleted) {
await Promise.all([
prisma.environment.update({
where: { id: environmentId },
data: { appSetupCompleted: true },
}),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
await prisma.environment.update({
where: { id: environmentId },
data: { appSetupCompleted: true },
});
}
// Check monthly response limits for Formbricks Cloud
@@ -50,23 +42,7 @@ export const getEnvironmentState = async (
isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
// Send plan limits event if needed
if (isMonthlyResponsesLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
miu: null,
responses: organization.billing.limits.monthly.responses,
},
},
});
} catch (err) {
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
// Limit check completed
}
// Build the response data

View File

@@ -9,7 +9,6 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
@@ -28,18 +27,10 @@ vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc),
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
@@ -145,26 +136,6 @@ describe("createResponse", () => {
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
@@ -186,20 +157,6 @@ describe("createResponse", () => {
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
});
describe("createResponseWithQuotaEvaluation", () => {

View File

@@ -6,11 +6,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact";
@@ -83,7 +81,6 @@ export const createResponse = async (
tx: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -121,8 +118,6 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -10,7 +10,6 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -172,11 +171,6 @@ export const POST = withV1ApiWrapper({
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {

View File

@@ -8,7 +8,6 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { validateInputs } from "@/lib/utils/validate";
@@ -96,9 +95,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
// Mock dependencies
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
@@ -118,10 +114,8 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -234,10 +228,9 @@ describe("Response Lib Tests", () => {
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
});
test("should check response limit and not send event if limit not reached", async () => {
test("should check response limit if limit not reached", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
@@ -251,32 +244,6 @@ describe("Response Lib Tests", () => {
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
const posthogError = new Error("Posthog error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
// Expecting successful response creation despite PostHog error
const response = await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
expect(response).toEqual(mockResponse); // Should still return the created response
});
});
});

View File

@@ -8,14 +8,12 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact";
@@ -93,7 +91,6 @@ export const createResponse = async (
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -131,8 +128,6 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -3,7 +3,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof ResourceNotFoundError) {

View File

@@ -12,9 +12,7 @@ import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";
@@ -49,9 +47,7 @@ vi.mock("@/lib/constants", () => ({
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({
@@ -166,9 +162,7 @@ describe("createResponse V2", () => {
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
}));
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false,
quotaFull: null,
@@ -183,26 +177,6 @@ describe("createResponse V2", () => {
mockIsFormbricksCloud = true;
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
@@ -225,20 +199,6 @@ describe("createResponse V2", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput, mockTx); // Should not throw
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = {

View File

@@ -6,12 +6,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact";
@@ -91,7 +89,6 @@ export const createResponse = async (
tx?: Prisma.TransactionClient
): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
@@ -129,8 +126,6 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -8,7 +8,6 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -148,11 +147,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
});
}
await capturePosthogEnvironmentEvent(environmentId, "response created", {
surveyId: responseData.surveyId,
surveyType: survey.type,
});
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {

View File

@@ -1891,8 +1891,9 @@ checksums:
setup/organization/create/no_membership_found: 6d6e792661c79452984dc671a2590847
setup/organization/create/no_membership_found_description: ba067dacf419041c8bcdb51ee6b0125b
setup/organization/create/title: 771924a960b2f7019cf8db55543c0715
setup/signup/create_administrator: e5b7e90150ebecf18a248f50a011bb7f
setup/signup/this_user_has_all_the_power: 1af3a7367d412d17f0f16c0e104b3520
setup/signup/create_administrator: bb44729d41138e400802509641de1d46
setup/signup/receive_security_updates: de5127f5847cdd412906607e1402f48d
setup/signup/security_updates_consent_description: 4643df07f13cec619e7fd91c8f14d93b
templates/address: 5a9a8bc26f90d84c90105690a2eb23a1
templates/address_description: e45b92c5cfff59ae7381e8bfb43b173f
templates/alignment_and_engagement_survey_description: a959f7abf4c7bc55371381a73d3113aa

View File

@@ -218,10 +218,6 @@ export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const POSTHOG_API_KEY = env.POSTHOG_API_KEY;
export const POSTHOG_API_HOST = env.POSTHOG_API_HOST;
export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);

View File

@@ -59,8 +59,6 @@ export const env = createEnv({
? z.string().optional()
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
POSTHOG_API_KEY: z.string().optional(),
PRIVACY_URL: z
.string()
.url()
@@ -103,7 +101,6 @@ export const env = createEnv({
}
)
.optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z
.string()
.url()
@@ -172,8 +169,6 @@ export const env = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
@@ -206,7 +201,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
PUBLIC_URL: process.env.PUBLIC_URL,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,

View File

@@ -17,7 +17,6 @@ import {
} from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "../organization/service";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { getUserProjects } from "../project/service";
import { validateInputs } from "../utils/validate";
@@ -173,10 +172,6 @@ export const createEnvironment = async (
},
});
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
environmentType: environment.type,
});
return environment;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -1,56 +0,0 @@
import { PostHog } from "posthog-node";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations";
import { cache } from "@/lib/cache";
import { IS_POSTHOG_CONFIGURED, IS_PRODUCTION, POSTHOG_API_HOST, POSTHOG_API_KEY } from "./constants";
const enabled = IS_PRODUCTION && IS_POSTHOG_CONFIGURED;
export const capturePosthogEnvironmentEvent = async (
environmentId: string,
eventName: string,
properties: any = {}
) => {
if (!enabled || typeof POSTHOG_API_HOST !== "string" || typeof POSTHOG_API_KEY !== "string") {
return;
}
try {
const client = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_API_HOST,
});
client.capture({
// workaround with a static string as exaplained in PostHog docs: https://posthog.com/docs/product-analytics/group-analytics
distinctId: "environmentEvents",
event: eventName,
groups: { environment: environmentId },
properties,
});
await client.shutdown();
} catch (error) {
logger.error(error, "error sending posthog event");
}
};
export const sendPlanLimitsReachedEventToPosthogWeekly = async (
environmentId: string,
billing: {
plan: TOrganizationBillingPlan;
limits: TOrganizationBillingPlanLimits;
}
) =>
await cache.withCache(
async () => {
try {
await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", {
...billing,
});
return "success";
} catch (error) {
logger.error(error, "error sending plan limits reached event to posthog weekly");
throw error;
}
},
createCacheKey.custom("analytics", environmentId, `plan_limits_${billing.plan}`),
60 * 60 * 24 * 7 * 1000 // 7 days in milliseconds
);

View File

@@ -13,7 +13,6 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { evaluateLogic } from "@/lib/surveyLogic/utils";
import {
mockActionClass,
@@ -44,11 +43,6 @@ vi.mock("@/lib/organization/service", () => ({
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
}));
// Mock posthogServer
vi.mock("@/lib/posthogServer", () => ({
capturePosthogEnvironmentEvent: vi.fn(),
}));
// Mock actionClass service
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
@@ -646,7 +640,6 @@ describe("Tests for createSurvey", () => {
expect(prisma.survey.create).toHaveBeenCalled();
expect(result.name).toEqual(mockSurveyOutput.name);
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
});
test("creates a private segment for app surveys", async () => {

View File

@@ -13,7 +13,6 @@ import {
} from "@/lib/organization/service";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { validateInputs } from "../utils/validate";
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
@@ -673,11 +672,6 @@ export const createSurvey = async (
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -1,38 +0,0 @@
/* We use this telemetry service to better understand how Formbricks is being used
and how we can improve it. All data including the IP address is collected anonymously
and we cannot trace anything back to you or your customers. If you still want to
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION } from "./constants";
import { env } from "./env";
const crypto = require("crypto");
// We are using the hashed CRON_SECRET as the distinct identifier for the instance for telemetry.
// The hash cannot be traced back to the original value or the instance itself.
// This is to ensure that the telemetry data is anonymous but still unique to the instance.
const getTelemetryId = (): string => {
return crypto.createHash("sha256").update(env.CRON_SECRET).digest("hex");
};
export const captureTelemetry = async (eventName: string, properties = {}) => {
if (env.TELEMETRY_DISABLED !== "1" && IS_PRODUCTION) {
try {
await fetch("https://telemetry.formbricks.com/capture/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret
event: eventName,
properties: {
distinct_id: getTelemetryId(),
...properties,
},
timestamp: new Date().toISOString(),
}),
});
} catch (error) {
logger.error(error, "error sending telemetry");
}
}
};

View File

@@ -2036,7 +2036,8 @@
},
"signup": {
"create_administrator": "Administrator erstellen",
"this_user_has_all_the_power": "Dieser Benutzer hat alle Rechte."
"receive_security_updates": "Sicherheitsupdates",
"security_updates_consent_description": "Nur sicherheitsrelevante Informationen, Datenschutzrichtlinie gilt."
}
},
"templates": {

View File

@@ -2035,8 +2035,9 @@
}
},
"signup": {
"create_administrator": "Create Administrator",
"this_user_has_all_the_power": "This user has all the power."
"create_administrator": "You're the admin!",
"receive_security_updates": "Security updates",
"security_updates_consent_description": "Security relevant information only, Privacy Policy applies."
}
},
"templates": {

View File

@@ -2035,8 +2035,9 @@
}
},
"signup": {
"create_administrator": "Crear administrador",
"this_user_has_all_the_power": "Este usuario tiene todo el poder."
"create_administrator": "¡Eres el administrador!",
"receive_security_updates": "Actualizaciones de seguridad",
"security_updates_consent_description": "Solo información relevante para la seguridad, se aplica la política de privacidad."
}
},
"templates": {

View File

@@ -2036,7 +2036,8 @@
},
"signup": {
"create_administrator": "Créer un administrateur",
"this_user_has_all_the_power": "Cet utilisateur a tout le pouvoir."
"receive_security_updates": "Mises à jour de sécurité",
"security_updates_consent_description": "Informations pertinentes pour la sécurité uniquement, la politique de confidentialité s'applique."
}
},
"templates": {

View File

@@ -2036,7 +2036,8 @@
},
"signup": {
"create_administrator": "管理者を作成",
"this_user_has_all_the_power": "このユーザーはすべての権限を持っています。"
"receive_security_updates": "セキュリティアップデート",
"security_updates_consent_description": "セキュリティに関連する情報のみ、プライバシーポリシーが適用されます。"
}
},
"templates": {

View File

@@ -2035,8 +2035,9 @@
}
},
"signup": {
"create_administrator": "Beheerder aanmaken",
"this_user_has_all_the_power": "Deze gebruiker heeft alle macht."
"create_administrator": "Je bent de beheerder!",
"receive_security_updates": "Beveiligingsupdates",
"security_updates_consent_description": "Alleen informatie relevant voor de beveiliging, privacybeleid is van toepassing."
}
},
"templates": {

View File

@@ -2036,7 +2036,8 @@
},
"signup": {
"create_administrator": "Criar Administrador",
"this_user_has_all_the_power": "Esse usuário tem todo o poder."
"receive_security_updates": "Atualizações de segurança",
"security_updates_consent_description": "Apenas informações relevantes de segurança, Política de Privacidade aplicável."
}
},
"templates": {

View File

@@ -2036,7 +2036,8 @@
},
"signup": {
"create_administrator": "Criar Administrador",
"this_user_has_all_the_power": "Este utilizador tem todo o poder."
"receive_security_updates": "Atualizações de segurança",
"security_updates_consent_description": "Apenas informações relevantes para a segurança, aplica-se a Política de Privacidade."
}
},
"templates": {

View File

@@ -2036,7 +2036,8 @@
},
"signup": {
"create_administrator": "Creare Administrator",
"this_user_has_all_the_power": "Acest utilizator are toată puterea."
"receive_security_updates": "Actualizări de securitate",
"security_updates_consent_description": "Doar informații relevante pentru securitate, se aplică Politica de confidențialitate."
}
},
"templates": {

View File

@@ -2036,7 +2036,8 @@
},
"signup": {
"create_administrator": "创建 管理员",
"this_user_has_all_the_power": "此 用户 拥有 所有 权力。"
"receive_security_updates": "安全更新",
"security_updates_consent_description": "仅限安全相关信息,适用隐私政策。"
}
},
"templates": {

View File

@@ -2035,8 +2035,9 @@
}
},
"signup": {
"create_administrator": "建立管理員",
"this_user_has_all_the_power": "此使用者擁有所有權限。"
"create_administrator": "您是管理員",
"receive_security_updates": "安全性更新",
"security_updates_consent_description": "僅限安全相關資訊,適用隱私權政策。"
}
},
"templates": {

View File

@@ -1,13 +1,10 @@
import "server-only";
import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
import {
getMonthlyOrganizationResponseCount,
@@ -51,8 +48,6 @@ export const createResponse = async (
responseInput: TResponseInput,
tx?: Prisma.TransactionClient
): Promise<Result<Response, ApiErrorResponseV2>> => {
captureTelemetry("response created");
const {
surveyId,
displayId,
@@ -126,7 +121,6 @@ export const createResponse = async (
if (!billing.ok) {
return err(billing.error as ApiErrorResponseV2);
}
const billingData = billing.data;
const prismaClient = tx ?? prisma;
@@ -140,26 +134,7 @@ export const createResponse = async (
return err(responsesCountResult.error as ApiErrorResponseV2);
}
const responsesCount = responsesCountResult.data;
const responsesLimit = billingData.limits?.monthly.responses;
if (responsesLimit && responsesCount >= responsesLimit) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: billingData.plan,
limits: {
projects: null,
monthly: {
responses: responsesLimit,
miu: null,
},
},
});
} catch (err) {
// Log error but do not throw it
logger.error(err, "Error sending plan limits reached event to Posthog");
}
}
// Limit check completed
}
return ok(response);

View File

@@ -12,7 +12,6 @@ import {
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import {
getMonthlyOrganizationResponseCount,
getOrganizationBilling,
@@ -20,10 +19,6 @@ import {
} from "@/modules/api/v2/management/responses/lib/organization";
import { createResponse, getResponses } from "../response";
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getOrganizationBilling: vi.fn(),
@@ -150,11 +145,8 @@ describe("Response Lib", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockImplementation(() => Promise.resolve(""));
const result = await createResponse(environmentId, responseInput);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(response);
@@ -191,10 +183,6 @@ describe("Response Lib", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(
new Error("Error sending plan limits")
);
const result = await createResponse(environmentId, responseInput);
expect(result.ok).toBe(true);
if (result.ok) {

View File

@@ -1,7 +1,6 @@
import { WebhookSource } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { captureTelemetry } from "@/lib/telemetry";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { createWebhook, getWebhooks } from "../webhook";
@@ -16,10 +15,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
describe("getWebhooks", () => {
const environmentId = "env1";
const params = {
@@ -86,7 +81,6 @@ describe("createWebhook", () => {
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
const result = await createWebhook(inputWebhook);
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
expect(prisma.webhook.create).toHaveBeenCalled();
expect(result.ok).toBe(true);

View File

@@ -1,7 +1,6 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -47,8 +46,6 @@ export const getWebhooks = async (
};
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
captureTelemetry("webhook_created");
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try {

View File

@@ -2,7 +2,6 @@ import { ProjectTeam } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import {
TGetProjectTeamsFilter,
@@ -44,8 +43,6 @@ export const getProjectTeams = async (
export const createProjectTeam = async (
teamInput: TProjectTeamInput
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
captureTelemetry("project team created");
const { teamId, projectId, permission } = teamInput;
try {

View File

@@ -2,7 +2,6 @@ import "server-only";
import { Team } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
import {
TGetTeamsFilter,
@@ -15,8 +14,6 @@ export const createTeam = async (
teamInput: TTeamInput,
organizationId: string
): Promise<Result<Team, ApiErrorResponseV2>> => {
captureTelemetry("team created");
const { name } = teamInput;
try {

View File

@@ -2,7 +2,6 @@ import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/database/zod/users";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { captureTelemetry } from "@/lib/telemetry";
import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
import {
TGetUsersFilter,
@@ -73,8 +72,6 @@ export const createUser = async (
userInput: TUserInput,
organizationId
): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user created");
const { name, email, role, teams, isActive } = userInput;
try {
@@ -150,8 +147,6 @@ export const updateUser = async (
userInput: TUserInputPatch,
organizationId: string
): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user updated");
const { name, email, role, teams, isActive } = userInput;
let existingTeams: string[] = [];
let newTeams;

View File

@@ -13,7 +13,7 @@ import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -46,21 +46,15 @@ const ZCreateUserAction = z.object({
),
});
async function verifyTurnstileIfConfigured(
turnstileToken: string | undefined,
email: string,
name: string
): Promise<void> {
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
if (!IS_TURNSTILE_CONFIGURED) return;
if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
captureFailedSignup(email, name);
throw new UnknownError("Server configuration error");
}
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
if (!isHuman) {
captureFailedSignup(email, name);
throw new UnknownError("reCAPTCHA verification failed");
}
}
@@ -180,7 +174,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.signup);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken);
const hashedPassword = await hashPassword(parsedInput.password);
const { user, userAlreadyExisted } = await createUserSafely(

View File

@@ -13,11 +13,12 @@ import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
import { captureFailedSignup } from "@/modules/auth/signup/lib/utils";
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { createEmailTokenAction } from "../../../auth/actions";
import { PasswordChecks } from "./password-checks";
@@ -49,6 +50,7 @@ interface SignupFormProps {
samlTenant: string;
samlProduct: string;
turnstileSiteKey?: string;
isAdminAccountCreation?: boolean;
}
export const SignupForm = ({
@@ -70,6 +72,7 @@ export const SignupForm = ({
samlTenant,
samlProduct,
turnstileSiteKey,
isAdminAccountCreation = false,
}: SignupFormProps) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
@@ -77,6 +80,7 @@ export const SignupForm = ({
const inviteToken = searchParams?.get("inviteToken");
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState<string>();
const [securityUpdatesConsent, setSecurityUpdatesConsent] = useState(true);
const turnstile = useTurnstile();
@@ -103,6 +107,47 @@ export const SignupForm = ({
throw new Error(t("auth.signup.please_verify_captcha"));
}
if (securityUpdatesConsent && isAdminAccountCreation) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await fetch("https://ee.formbricks.com/api/security-updates/consent", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: data.email,
name: data.name,
consent: true,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error("API call failed");
}
} catch {
toast.error(
<div>
Security list sign up didn&apos;t work, please sign up here manually.{" "}
<a
href="https://app.formbricks.com/s/cmgeq5ao90n4jvl01s9beewcu"
target="_blank"
rel="noopener noreferrer"
className="underline">
Sign up here
</a>
</div>,
{ duration: 10000 }
);
setSecurityUpdatesConsent(false);
}
}
const createUserResponse = await createUserAction({
name: data.name,
email: data.email,
@@ -225,6 +270,24 @@ export const SignupForm = ({
/>
</div>
<PasswordChecks password={form.watch("password")} />
{showLogin && isAdminAccountCreation && (
<div className="my-2 flex items-start space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2">
<Checkbox
id="security-updates-consent"
checked={securityUpdatesConsent}
onCheckedChange={(checked) => setSecurityUpdatesConsent(checked === true)}
className="mt-0.5"
/>
<div className="flex-1 text-left">
<Label htmlFor="security-updates-consent" className="text-sm">
{t("setup.signup.receive_security_updates")}
</Label>
<p className="mt-1 text-xs text-slate-600">
{t("setup.signup.security_updates_consent_description")}
</p>
</div>
</div>
)}
</div>
)}
{isTurnstileConfigured && showLogin && turnstileSiteKey && (
@@ -236,7 +299,6 @@ export const SignupForm = ({
onError={() => {
setTurnstileToken(undefined);
toast.error(t("auth.signup.captcha_failed"));
captureFailedSignup(form.getValues("email"), form.getValues("name"));
}}
/>
)}
@@ -281,15 +343,18 @@ export const SignupForm = ({
/>
)}
<TermsPrivacyLinks termsUrl={termsUrl} privacyUrl={privacyUrl} />
<div className="mt-9 text-center text-xs">
<span className="leading-5 text-slate-500">{t("auth.signup.have_an_account")}</span>
<br />
<Link
href={inviteToken ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login"}
className="font-semibold text-slate-600 underline hover:text-slate-700">
{t("auth.signup.log_in")}
</Link>
</div>
{!isAdminAccountCreation && (
<div className="mt-9 text-center text-xs">
<span className="leading-5 text-slate-500">{t("auth.signup.have_an_account")}</span>
<br />
<Link
href={inviteToken ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login"}
className="font-semibold text-slate-600 underline hover:text-slate-700">
{t("auth.signup.log_in")}
</Link>
</div>
)}
</div>
);
};

View File

@@ -26,7 +26,6 @@ export const TermsPrivacyLinks = ({ termsUrl, privacyUrl }: TermsPrivacyLinksPro
{t("auth.signup.privacy_policy")}
</Link>
)}
<hr className="mx-6 mt-3"></hr>
</div>
);
};

View File

@@ -1,6 +1,5 @@
import posthog from "posthog-js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { captureFailedSignup, verifyTurnstileToken } from "./utils";
import { verifyTurnstileToken } from "./utils";
beforeEach(() => {
global.fetch = vi.fn();
@@ -62,18 +61,3 @@ describe("verifyTurnstileToken", () => {
expect(result).toBe(false);
});
});
describe("captureFailedSignup", () => {
test("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => {
const captureSpy = vi.spyOn(posthog, "capture");
const email = "test@example.com";
const name = "Test User";
captureFailedSignup(email, name);
expect(captureSpy).toHaveBeenCalledWith("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
});
});

View File

@@ -1,5 +1,3 @@
import posthog from "posthog-js";
export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -29,10 +27,3 @@ export const verifyTurnstileToken = async (secretKey: string, token: string): Pr
clearTimeout(timeoutId);
}
};
export const captureFailedSignup = (email: string, name: string) => {
posthog.capture("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
};

View File

@@ -56,9 +56,6 @@ vi.mock("@/lib/constants", () => ({
ITEMS_PER_PAGE: 2,
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
IS_PRODUCTION: false,
IS_POSTHOG_CONFIGURED: false,
POSTHOG_API_HOST: "test-posthog-host",
POSTHOG_API_KEY: "test-posthog-key",
}));
const environmentId = "cm123456789012345678901237";

View File

@@ -4,7 +4,6 @@ import fetch from "node-fetch";
import { cache as reactCache } from "react";
import { z } from "zod";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { env } from "@/lib/env";
@@ -13,6 +12,7 @@ import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { collectTelemetryData } from "./telemetry";
// Configuration
const CONFIG = {
@@ -246,29 +246,48 @@ const handleInitialFailure = async (currentTime: Date) => {
// API functions
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
// Skip license checks during build time
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
if (process.env.NEXT_PHASE === "phase-production-build") {
return null;
}
let telemetryData;
try {
const now = new Date();
const startOfYear = new Date(now.getFullYear(), 0, 1);
// first millisecond of next year => current year is fully included
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
telemetryData = await collectTelemetryData(env.ENTERPRISE_LICENSE_KEY || null);
} catch (telemetryError) {
logger.warn({ error: telemetryError }, "Telemetry collection failed, proceeding with minimal data");
telemetryData = {
licenseKey: env.ENTERPRISE_LICENSE_KEY || null,
usage: null,
};
}
const responseCount = await prisma.response.count({
where: {
createdAt: {
gte: startOfYear,
lt: startOfNextYear,
},
},
});
if (!env.ENTERPRISE_LICENSE_KEY) {
try {
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
await fetch(CONFIG.API.ENDPOINT, {
body: JSON.stringify(telemetryData),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
signal: controller.signal,
});
clearTimeout(timeoutId);
} catch (error) {
logger.debug({ error }, "Failed to send telemetry (no license key)");
}
return null;
}
try {
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
@@ -276,10 +295,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
const res = await fetch(CONFIG.API.ENDPOINT, {
body: JSON.stringify({
licenseKey: env.ENTERPRISE_LICENSE_KEY,
usage: { responseCount },
}),
body: JSON.stringify(telemetryData),
headers: { "Content-Type": "application/json" },
method: "POST",
agent,
@@ -296,7 +312,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
trackApiError(error);
// Retry on specific status codes
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
return fetchLicenseFromServerInternal(retryCount + 1);
@@ -341,6 +356,10 @@ export const getEnterpriseLicense = reactCache(
validateConfig();
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
fetchLicenseFromServerInternal().catch((error) => {
logger.debug({ error }, "Background telemetry send failed (no license key)");
});
return {
active: false,
features: null,

View File

@@ -0,0 +1,245 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { collectTelemetryData } from "./telemetry";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
organization: { count: vi.fn(), findFirst: vi.fn() },
user: { count: vi.fn(), findFirst: vi.fn() },
team: { count: vi.fn() },
project: { count: vi.fn() },
survey: { count: vi.fn(), findFirst: vi.fn() },
contact: { count: vi.fn() },
segment: { count: vi.fn() },
display: { count: vi.fn() },
response: { count: vi.fn() },
surveyLanguage: { findFirst: vi.fn() },
surveyAttributeFilter: { findFirst: vi.fn() },
apiKey: { findFirst: vi.fn() },
teamUser: { findFirst: vi.fn() },
surveyQuota: { findFirst: vi.fn() },
webhook: { findFirst: vi.fn() },
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_STORAGE_CONFIGURED: true,
IS_RECAPTCHA_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
GOOGLE_OAUTH_ENABLED: true,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
AIRTABLE_CLIENT_ID: "test-airtable-id",
SLACK_CLIENT_ID: "test-slack-id",
SLACK_CLIENT_SECRET: "test-slack-secret",
NOTION_OAUTH_CLIENT_ID: "test-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "test-notion-secret",
GOOGLE_SHEETS_CLIENT_ID: "test-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "test-sheets-secret",
}));
describe("Telemetry Collection", () => {
const mockLicenseKey = "test-license-key-123";
const mockOrganizationId = "org-123";
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: mockOrganizationId,
createdAt: new Date(),
} as any);
});
afterEach(() => {
vi.useRealTimers();
});
describe("collectTelemetryData", () => {
test("should return null usage for cloud instances", async () => {
// Mock IS_FORMBRICKS_CLOUD as true for this test
const actualConstants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...(actualConstants as Record<string, unknown>),
IS_FORMBRICKS_CLOUD: true,
}));
// Re-import to get the new mock
const { collectTelemetryData: collectWithCloud } = await import("./telemetry");
const result = await collectWithCloud(mockLicenseKey);
expect(result.licenseKey).toBe(mockLicenseKey);
expect(result.usage).toBeNull();
// Reset mock
vi.resetModules();
});
test("should collect basic counts successfully", async () => {
vi.mocked(prisma.organization.count).mockResolvedValue(1);
vi.mocked(prisma.user.count).mockResolvedValue(5);
vi.mocked(prisma.team.count).mockResolvedValue(2);
vi.mocked(prisma.project.count).mockResolvedValue(3);
vi.mocked(prisma.survey.count).mockResolvedValue(10);
vi.mocked(prisma.contact.count).mockResolvedValue(100);
vi.mocked(prisma.segment.count).mockResolvedValue(5);
vi.mocked(prisma.display.count).mockResolvedValue(500);
vi.mocked(prisma.response.count).mockResolvedValue(1000);
const result = await collectTelemetryData(mockLicenseKey);
expect(result.usage).toBeTruthy();
if (result.usage) {
expect(result.usage.organizationCount).toBe(1);
expect(result.usage.memberCount).toBe(5);
expect(result.usage.teamCount).toBe(2);
expect(result.usage.projectCount).toBe(3);
expect(result.usage.surveyCount).toBe(10);
expect(result.usage.contactCount).toBe(100);
expect(result.usage.segmentCount).toBe(5);
expect(result.usage.surveyDisplayCount).toBe(500);
expect(result.usage.responseCountAllTime).toBe(1000);
}
});
test("should handle query timeouts gracefully", async () => {
// Simulate slow query that times out (but resolve it eventually)
let resolveOrgCount: (value: number) => void;
const orgCountPromise = new Promise<number>((resolve) => {
resolveOrgCount = resolve;
});
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
// Mock other queries to return quickly
vi.mocked(prisma.user.count).mockResolvedValue(5);
vi.mocked(prisma.team.count).mockResolvedValue(2);
vi.mocked(prisma.project.count).mockResolvedValue(3);
vi.mocked(prisma.survey.count).mockResolvedValue(10);
vi.mocked(prisma.contact.count).mockResolvedValue(100);
vi.mocked(prisma.segment.count).mockResolvedValue(5);
vi.mocked(prisma.display.count).mockResolvedValue(500);
vi.mocked(prisma.response.count).mockResolvedValue(1000);
// Mock batch 2 queries
vi.mocked(prisma.survey.findFirst).mockResolvedValue({ id: "survey-1" } as any);
// Start collection
const resultPromise = collectTelemetryData(mockLicenseKey);
// Advance timers past the 2s query timeout
await vi.advanceTimersByTimeAsync(3000);
// Resolve the slow query after timeout
resolveOrgCount!(1);
const result = await resultPromise;
// Should still return result, but with null values for timed-out queries
expect(result.usage).toBeTruthy();
expect(result.usage?.organizationCount).toBeNull();
// Other queries should still work
expect(result.usage?.memberCount).toBe(5);
}, 15000);
test("should handle database errors gracefully", async () => {
const dbError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.organization.count).mockRejectedValue(dbError);
vi.mocked(prisma.user.count).mockResolvedValue(5);
const result = await collectTelemetryData(mockLicenseKey);
// Should continue despite errors
expect(result.usage).toBeTruthy();
expect(result.usage?.organizationCount).toBeNull();
expect(result.usage?.memberCount).toBe(5);
});
test("should detect feature usage correctly", async () => {
// Mock feature detection queries
vi.mocked(prisma.surveyLanguage.findFirst).mockResolvedValue({ languageId: "en" } as any);
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce({
id: "user-2",
twoFactorEnabled: true,
} as any);
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue({ id: "key-1" } as any);
// Mock all count queries to return 0 to avoid complexity
vi.mocked(prisma.organization.count).mockResolvedValue(0);
vi.mocked(prisma.user.count).mockResolvedValue(0);
vi.mocked(prisma.team.count).mockResolvedValue(0);
vi.mocked(prisma.project.count).mockResolvedValue(0);
vi.mocked(prisma.survey.count).mockResolvedValue(0);
vi.mocked(prisma.contact.count).mockResolvedValue(0);
vi.mocked(prisma.segment.count).mockResolvedValue(0);
vi.mocked(prisma.display.count).mockResolvedValue(0);
vi.mocked(prisma.response.count).mockResolvedValue(0);
const result = await collectTelemetryData(mockLicenseKey);
expect(result.usage?.featureUsage).toBeTruthy();
if (result.usage?.featureUsage) {
expect(result.usage.featureUsage.multiLanguageSurveys).toBe(true);
expect(result.usage.featureUsage.twoFA).toBe(true);
expect(result.usage.featureUsage.apiKeys).toBe(true);
expect(result.usage.featureUsage.sso).toBe(true); // From constants
expect(result.usage.featureUsage.fileUpload).toBe(true); // From constants
}
});
test("should generate instance ID when no organization exists", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
const result = await collectTelemetryData(mockLicenseKey);
expect(result.usage).toBeTruthy();
expect(result.usage?.instanceId).toBeTruthy();
expect(typeof result.usage?.instanceId).toBe("string");
});
test("should handle total timeout gracefully", async () => {
let resolveOrgFind: (value: any) => void;
const orgFindPromise = new Promise<any>((resolve) => {
resolveOrgFind = resolve;
});
vi.mocked(prisma.organization.findFirst).mockImplementation(() => orgFindPromise as any);
let resolveOrgCount: (value: number) => void;
const orgCountPromise = new Promise<number>((resolve) => {
resolveOrgCount = resolve;
});
vi.mocked(prisma.organization.count).mockImplementation(() => orgCountPromise as any);
// Start collection
const resultPromise = collectTelemetryData(mockLicenseKey);
// Advance timers past the 15s total timeout
await vi.advanceTimersByTimeAsync(16000);
resolveOrgFind!({ id: mockOrganizationId, createdAt: new Date() });
resolveOrgCount!(1);
const result = await resultPromise;
// Should return usage object (may be empty or partial)
expect(result.usage).toBeTruthy();
}, 20000);
});
});

View File

@@ -0,0 +1,630 @@
import "server-only";
import crypto from "node:crypto";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import {
AIRTABLE_CLIENT_ID,
AUDIT_LOG_ENABLED,
AZURE_OAUTH_ENABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
IS_FORMBRICKS_CLOUD,
IS_RECAPTCHA_CONFIGURED,
IS_STORAGE_CONFIGURED,
NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET,
OIDC_OAUTH_ENABLED,
SAML_OAUTH_ENABLED,
SLACK_CLIENT_ID,
SLACK_CLIENT_SECRET,
} from "@/lib/constants";
const CONFIG = {
QUERY_TIMEOUT_MS: 2000,
BATCH_TIMEOUT_MS: 5000,
TOTAL_TIMEOUT_MS: 15000,
} as const;
export type TelemetryUsage = {
instanceId: string;
organizationCount: number | null;
memberCount: number | null;
teamCount: number | null;
projectCount: number | null;
surveyCount: number | null;
activeSurveyCount: number | null;
completedSurveyCount: number | null;
responseCountAllTime: number | null;
responseCountLast30d: number | null;
surveyDisplayCount: number | null;
contactCount: number | null;
segmentCount: number | null;
featureUsage: {
multiLanguageSurveys: boolean | null;
advancedTargeting: boolean | null;
sso: boolean | null;
saml: boolean | null;
twoFA: boolean | null;
apiKeys: boolean | null;
teamRoles: boolean | null;
auditLogs: boolean | null;
whitelabel: boolean | null;
removeBranding: boolean | null;
fileUpload: boolean | null;
spamProtection: boolean | null;
quotas: boolean | null;
};
activeIntegrations: {
airtable: boolean | null;
slack: boolean | null;
notion: boolean | null;
googleSheets: boolean | null;
zapier: boolean | null;
make: boolean | null;
n8n: boolean | null;
webhook: boolean | null;
};
temporal: {
instanceCreatedAt: string | null;
newestSurveyDate: string | null;
};
};
export type TelemetryData = {
licenseKey: string | null;
usage: TelemetryUsage | null;
};
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
return Promise.race([
promise,
new Promise<T | null>((resolve) => {
setTimeout(() => {
logger.warn({ timeoutMs }, "Query timeout exceeded");
resolve(null);
}, timeoutMs);
}),
]);
};
const safeQuery = async <T>(
queryFn: () => Promise<T>,
queryName: string,
batchNumber: number
): Promise<T | null> => {
try {
const result = await withTimeout(queryFn(), CONFIG.QUERY_TIMEOUT_MS);
return result;
} catch (error) {
logger.error(
{
error,
queryName,
batchNumber,
},
`Telemetry query failed: ${queryName}`
);
return null;
}
};
const getInstanceId = async (): Promise<string> => {
try {
const firstOrg = await withTimeout(
prisma.organization.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
}),
CONFIG.QUERY_TIMEOUT_MS
);
if (!firstOrg) {
return crypto.randomUUID();
}
return crypto.createHash("sha256").update(firstOrg.id).digest("hex").substring(0, 32);
} catch (error) {
logger.error({ error }, "Failed to get instance ID, using random UUID");
return crypto.randomUUID();
}
};
const collectBatch1 = async (): Promise<Partial<TelemetryUsage>> => {
const queries = [
{
name: "organizationCount",
fn: () => prisma.organization.count(),
},
{
name: "memberCount",
fn: () => prisma.user.count(),
},
{
name: "teamCount",
fn: () => prisma.team.count(),
},
{
name: "projectCount",
fn: () => prisma.project.count(),
},
{
name: "surveyCount",
fn: () => prisma.survey.count(),
},
{
name: "contactCount",
fn: () => prisma.contact.count(),
},
{
name: "segmentCount",
fn: () => prisma.segment.count(),
},
{
name: "surveyDisplayCount",
fn: () => prisma.display.count(),
},
{
name: "responseCountAllTime",
fn: () => prisma.response.count(),
},
];
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 1)));
const batchResult: Partial<TelemetryUsage> = {};
for (const [index, result] of results.entries()) {
const key = queries[index].name as keyof TelemetryUsage;
if (result.status === "fulfilled" && result.value !== null) {
(batchResult as Record<string, unknown>)[key] = result.value;
} else {
(batchResult as Record<string, unknown>)[key] = null;
}
}
return batchResult;
};
const collectBatch2 = async (): Promise<Partial<TelemetryUsage>> => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const queries = [
{
name: "activeSurveyCount",
fn: () => prisma.survey.count({ where: { status: "inProgress" } }),
},
{
name: "completedSurveyCount",
fn: () => prisma.survey.count({ where: { status: "completed" } }),
},
{
name: "responseCountLast30d",
fn: () =>
prisma.response.count({
where: {
createdAt: {
gte: thirtyDaysAgo,
},
},
}),
},
];
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 2)));
const batchResult: Partial<TelemetryUsage> = {};
for (const [index, result] of results.entries()) {
const key = queries[index].name as keyof TelemetryUsage;
if (result.status === "fulfilled" && result.value !== null) {
(batchResult as Record<string, unknown>)[key] = result.value;
} else {
(batchResult as Record<string, unknown>)[key] = null;
}
}
return batchResult;
};
const collectBatch3 = async (): Promise<Partial<TelemetryUsage>> => {
const queries = [
{
name: "multiLanguageSurveys",
fn: async () => {
const result = await prisma.surveyLanguage.findFirst({ select: { languageId: true } });
return result !== null;
},
},
{
name: "advancedTargeting",
fn: async () => {
const [hasFilters, hasSegments] = await Promise.all([
prisma.surveyAttributeFilter.findFirst({ select: { id: true } }),
prisma.survey.findFirst({ where: { segmentId: { not: null } } }),
]);
return hasFilters !== null || hasSegments !== null;
},
},
{
name: "twoFA",
fn: async () => {
const result = await prisma.user.findFirst({
where: { twoFactorEnabled: true },
select: { id: true },
});
return result !== null;
},
},
{
name: "apiKeys",
fn: async () => {
const result = await prisma.apiKey.findFirst({ select: { id: true } });
return result !== null;
},
},
{
name: "teamRoles",
fn: async () => {
const result = await prisma.teamUser.findFirst({ select: { teamId: true } });
return result !== null;
},
},
{
name: "whitelabel",
fn: async () => {
const organizations = await prisma.organization.findMany({
select: { whitelabel: true },
take: 100,
});
return organizations.some((org) => {
const whitelabel = org.whitelabel as Record<string, unknown> | null;
return whitelabel !== null && typeof whitelabel === "object" && Object.keys(whitelabel).length > 0;
});
},
},
{
name: "removeBranding",
fn: async () => {
const organizations = await prisma.organization.findMany({
select: { billing: true },
take: 100,
});
return organizations.some((org) => {
const billing = org.billing as { plan?: string; removeBranding?: boolean } | null;
return billing?.removeBranding === true;
});
},
},
{
name: "quotas",
fn: async () => {
const result = await prisma.surveyQuota.findFirst({ select: { id: true } });
return result !== null;
},
},
];
const results = await Promise.allSettled(queries.map((query) => safeQuery(query.fn, query.name, 3)));
const batchResult: Partial<TelemetryUsage> = {
featureUsage: {
multiLanguageSurveys: null,
advancedTargeting: null,
sso: null,
saml: null,
twoFA: null,
apiKeys: null,
teamRoles: null,
auditLogs: null,
whitelabel: null,
removeBranding: null,
fileUpload: null,
spamProtection: null,
quotas: null,
},
};
const featureMap: Record<string, keyof TelemetryUsage["featureUsage"]> = {
multiLanguageSurveys: "multiLanguageSurveys",
advancedTargeting: "advancedTargeting",
twoFA: "twoFA",
apiKeys: "apiKeys",
teamRoles: "teamRoles",
whitelabel: "whitelabel",
removeBranding: "removeBranding",
quotas: "quotas",
};
for (const [index, result] of results.entries()) {
const queryName = queries[index].name;
const featureKey = featureMap[queryName];
if (featureKey && batchResult.featureUsage) {
if (result.status === "fulfilled" && result.value !== null) {
batchResult.featureUsage[featureKey] = result.value;
} else {
batchResult.featureUsage[featureKey] = null;
}
}
}
if (batchResult.featureUsage) {
batchResult.featureUsage.sso =
GOOGLE_OAUTH_ENABLED ||
GITHUB_OAUTH_ENABLED ||
AZURE_OAUTH_ENABLED ||
OIDC_OAUTH_ENABLED ||
SAML_OAUTH_ENABLED;
batchResult.featureUsage.saml = SAML_OAUTH_ENABLED;
batchResult.featureUsage.auditLogs = AUDIT_LOG_ENABLED;
batchResult.featureUsage.fileUpload = IS_STORAGE_CONFIGURED;
batchResult.featureUsage.spamProtection = IS_RECAPTCHA_CONFIGURED;
}
return batchResult;
};
const collectBatch4 = async (): Promise<Partial<TelemetryUsage>> => {
const booleanQueries = [
{
name: "zapier",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "zapier" },
select: { id: true },
});
return result !== null;
},
},
{
name: "make",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "make" },
select: { id: true },
});
return result !== null;
},
},
{
name: "n8n",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "n8n" },
select: { id: true },
});
return result !== null;
},
},
{
name: "webhook",
fn: async (): Promise<boolean> => {
const result = await prisma.webhook.findFirst({
where: { source: "user" },
select: { id: true },
});
return result !== null;
},
},
];
const stringQueries = [
{
name: "instanceCreatedAt",
fn: async (): Promise<string | null> => {
const result = await prisma.user.findFirst({
orderBy: { createdAt: "asc" },
select: { createdAt: true },
});
return result?.createdAt.toISOString() ?? null;
},
},
{
name: "newestSurveyDate",
fn: async (): Promise<string | null> => {
const result = await prisma.survey.findFirst({
orderBy: { createdAt: "desc" },
select: { createdAt: true },
});
return result?.createdAt.toISOString() ?? null;
},
},
];
const booleanResults = await Promise.allSettled(
booleanQueries.map((query) => safeQuery(query.fn, query.name, 4))
);
const stringResults = await Promise.allSettled(
stringQueries.map((query) => safeQuery(query.fn, query.name, 4))
);
const batchResult: Partial<TelemetryUsage> = {
activeIntegrations: {
airtable: null,
slack: null,
notion: null,
googleSheets: null,
zapier: null,
make: null,
n8n: null,
webhook: null,
},
temporal: {
instanceCreatedAt: null,
newestSurveyDate: null,
},
};
const integrationMap: Record<string, keyof TelemetryUsage["activeIntegrations"]> = {
zapier: "zapier",
make: "make",
n8n: "n8n",
webhook: "webhook",
};
for (const [index, result] of booleanResults.entries()) {
const queryName = booleanQueries[index].name;
const integrationKey = integrationMap[queryName];
if (integrationKey && batchResult.activeIntegrations) {
if (result.status === "fulfilled" && result.value !== null) {
batchResult.activeIntegrations[integrationKey] = result.value;
} else {
batchResult.activeIntegrations[integrationKey] = null;
}
}
}
for (const [index, result] of stringResults.entries()) {
const queryName = stringQueries[index].name;
if (batchResult.temporal && (queryName === "instanceCreatedAt" || queryName === "newestSurveyDate")) {
if (result.status === "fulfilled" && result.value !== null) {
batchResult.temporal[queryName] = result.value;
}
}
}
if (batchResult.activeIntegrations) {
batchResult.activeIntegrations.airtable = !!AIRTABLE_CLIENT_ID;
batchResult.activeIntegrations.slack = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
batchResult.activeIntegrations.notion = !!(NOTION_OAUTH_CLIENT_ID && NOTION_OAUTH_CLIENT_SECRET);
batchResult.activeIntegrations.googleSheets = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET);
}
return batchResult;
};
export const collectTelemetryData = async (licenseKey: string | null): Promise<TelemetryData> => {
if (IS_FORMBRICKS_CLOUD) {
return {
licenseKey,
usage: null,
};
}
const startTime = Date.now();
try {
const instanceId = await getInstanceId();
const batchPromises = [
Promise.race([
collectBatch1(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 1 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch2(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 2 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch3(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 3 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
Promise.race([
collectBatch4(),
new Promise<Partial<TelemetryUsage>>((resolve) => {
setTimeout(() => {
logger.warn("Batch 4 timeout");
resolve({});
}, CONFIG.BATCH_TIMEOUT_MS);
}),
]),
];
const batchResults = await Promise.race([
Promise.all(batchPromises),
new Promise<Partial<TelemetryUsage>[]>((resolve) => {
setTimeout(() => {
logger.warn("Total telemetry collection timeout");
resolve([{}, {}, {}, {}]);
}, CONFIG.TOTAL_TIMEOUT_MS);
}),
]);
const usage: TelemetryUsage = {
instanceId,
organizationCount: null,
memberCount: null,
teamCount: null,
projectCount: null,
surveyCount: null,
activeSurveyCount: null,
completedSurveyCount: null,
responseCountAllTime: null,
responseCountLast30d: null,
surveyDisplayCount: null,
contactCount: null,
segmentCount: null,
featureUsage: {
multiLanguageSurveys: null,
advancedTargeting: null,
sso: null,
saml: null,
twoFA: null,
apiKeys: null,
teamRoles: null,
auditLogs: null,
whitelabel: null,
removeBranding: null,
fileUpload: null,
spamProtection: null,
quotas: null,
},
activeIntegrations: {
airtable: null,
slack: null,
notion: null,
googleSheets: null,
zapier: null,
make: null,
n8n: null,
webhook: null,
},
temporal: {
instanceCreatedAt: null,
newestSurveyDate: null,
},
};
for (const batchResult of batchResults) {
Object.assign(usage, batchResult);
if (batchResult.featureUsage) {
Object.assign(usage.featureUsage, batchResult.featureUsage);
}
if (batchResult.activeIntegrations) {
Object.assign(usage.activeIntegrations, batchResult.activeIntegrations);
}
if (batchResult.temporal) {
Object.assign(usage.temporal, batchResult.temporal);
}
}
const duration = Date.now() - startTime;
logger.info({ duration, instanceId }, "Telemetry collection completed");
return {
licenseKey,
usage,
};
} catch (error) {
logger.error({ error, duration: Date.now() - startTime }, "Telemetry collection failed completely");
return {
licenseKey,
usage: null,
};
}
};

View File

@@ -36,9 +36,7 @@ export const SignupPage = async () => {
const t = await getTranslate();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<p className="text-sm text-slate-800">{t("setup.signup.this_user_has_all_the_power")}</p>
<hr className="my-6 w-full border-slate-200" />
<h2 className="mb-2 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<SignupForm
webAppUrl={WEBAPP_URL}
termsUrl={TERMS_URL}
@@ -57,6 +55,7 @@ export const SignupPage = async () => {
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
turnstileSiteKey={TURNSTILE_SITE_KEY}
isAdminAccountCreation
/>
</div>
);

View File

@@ -9,16 +9,11 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { selectSurvey } from "@/modules/survey/lib/survey";
import { createSurvey, handleTriggerUpdates } from "./survey";
// Mock dependencies
vi.mock("@/lib/posthogServer", () => ({
capturePosthogEnvironmentEvent: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
checkForInvalidImagesInQuestions: vi.fn(),
}));
@@ -121,11 +116,6 @@ describe("survey module", () => {
"user-123",
"org-123"
);
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(
environmentId,
"survey created",
expect.objectContaining({ surveyId: "survey-123" })
);
expect(result).toBeDefined();
expect(result.id).toBe("survey-123");
});

View File

@@ -7,7 +7,6 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
@@ -122,11 +121,6 @@ export const createSurvey = async (
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
}
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {

View File

@@ -1,37 +1,13 @@
import { Session } from "next-auth";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
interface EnvironmentIdBaseLayoutProps {
children: React.ReactNode;
environmentId: string;
session: Session;
user: TUser;
organization: TOrganization;
}
export const EnvironmentIdBaseLayout = async ({
children,
environmentId,
session,
user,
organization,
}: EnvironmentIdBaseLayoutProps) => {
export const EnvironmentIdBaseLayout = async ({ children }: EnvironmentIdBaseLayoutProps) => {
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient />
{children}
</ResponseFilterProvider>

View File

@@ -1,56 +0,0 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import React, { type JSX, useEffect } from "react";
interface PostHogPageviewProps {
posthogEnabled: boolean;
postHogApiHost?: string;
postHogApiKey?: string;
}
export const PostHogPageview = ({
posthogEnabled,
postHogApiHost,
postHogApiKey,
}: PostHogPageviewProps): JSX.Element => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!posthogEnabled) return;
try {
if (!postHogApiHost) {
throw new Error("Posthog API host is required");
}
if (!postHogApiKey) {
throw new Error("Posthog key is required");
}
posthog.init(postHogApiKey, { api_host: postHogApiHost });
} catch (error) {
console.error("Failed to initialize PostHog:", error);
}
}, []);
useEffect(() => {
if (!posthogEnabled) return;
let url = window.origin + pathname;
if (searchParams?.toString()) {
url += `?${searchParams.toString()}`;
}
posthog.capture("$pageview", { $current_url: url });
}, [pathname, searchParams, posthogEnabled]);
return <></>;
};
interface PHPProviderProps {
children: React.ReactNode;
posthogEnabled: boolean;
}
export const PHProvider = ({ children, posthogEnabled }: PHPProviderProps) => {
return posthogEnabled ? <PostHogProvider client={posthog}>{children}</PostHogProvider> : children;
};

View File

@@ -108,8 +108,6 @@
"nodemailer": "7.0.9",
"otplib": "12.0.1",
"papaparse": "5.5.2",
"posthog-js": "1.240.0",
"posthog-node": "5.9.2",
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",

View File

@@ -57,7 +57,6 @@ export default defineConfig({
"**/actions.ts", // Server actions (plural)
"**/action.ts", // Server actions (singular)
"lib/env.ts", // Environment configuration
"lib/posthogServer.ts", // PostHog server integration
"**/cache.ts", // Cache files
"**/cache/**", // Cache directories

View File

@@ -186,9 +186,6 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",

View File

@@ -52,7 +52,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| 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_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |

52
pnpm-lock.yaml generated
View File

@@ -377,12 +377,6 @@ importers:
papaparse:
specifier: 5.5.2
version: 5.5.2
posthog-js:
specifier: 1.240.0
version: 1.240.0
posthog-node:
specifier: 5.9.2
version: 5.9.2
prismjs:
specifier: 1.30.0
version: 1.30.0
@@ -2882,9 +2876,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@posthog/core@1.2.2':
resolution: {integrity: sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg==}
'@preact/preset-vite@2.10.1':
resolution: {integrity: sha512-59lyGBXNfZIr5OOuBUB4/IB8AqF/ULbvYnyItgK/2BJnsGJqaeaJobRVtMp1129obHQuj8oZ/dVxB9inmH8Xig==}
peerDependencies:
@@ -5687,9 +5678,6 @@ packages:
core-js-compat@3.46.0:
resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==}
core-js@3.46.0:
resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -6462,9 +6450,6 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.7.4:
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
@@ -8246,21 +8231,6 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
posthog-js@1.240.0:
resolution: {integrity: sha512-zZhedVycGracBMWVRvWJMkB2EiB/dUoe/eM+CsFCnda/PN3Se+V7a6CLGuLZKLF9EfHswCxxU/PIxgDrhbAgjQ==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
posthog-node@5.9.2:
resolution: {integrity: sha512-oU7FbFcH5cn40nhP04cBeT67zE76EiGWjKKzDvm6IOm5P83sqM0Ij0wMJQSHp+QI6ZN7MLzb+4xfMPUEZ4q6CA==}
engines: {node: '>=20'}
preact-render-to-string@5.2.6:
resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==}
peerDependencies:
@@ -9838,9 +9808,6 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -13121,8 +13088,6 @@ snapshots:
dependencies:
playwright: 1.56.1
'@posthog/core@1.2.2': {}
'@preact/preset-vite@2.10.1(@babel/core@7.28.5)(preact@10.26.6)(vite@6.4.1(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.1))':
dependencies:
'@babel/core': 7.28.5
@@ -16287,8 +16252,6 @@ snapshots:
dependencies:
browserslist: 4.27.0
core-js@3.46.0: {}
core-util-is@1.0.3: {}
create-require@1.1.1: {}
@@ -17251,8 +17214,6 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fflate@0.4.8: {}
fflate@0.7.4: {}
file-entry-cache@6.0.1:
@@ -19128,17 +19089,6 @@ snapshots:
dependencies:
xtend: 4.0.2
posthog-js@1.240.0:
dependencies:
core-js: 3.46.0
fflate: 0.4.8
preact: 10.26.6
web-vitals: 4.2.4
posthog-node@5.9.2:
dependencies:
'@posthog/core': 1.2.2
preact-render-to-string@5.2.6(preact@10.26.6):
dependencies:
preact: 10.26.6
@@ -20863,8 +20813,6 @@ snapshots:
web-streams-polyfill@3.3.3: {}
web-vitals@4.2.4: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}

View File

@@ -172,8 +172,6 @@
"OIDC_SIGNING_ALGORITHM",
"PASSWORD_RESET_DISABLED",
"PLAYWRIGHT_CI",
"POSTHOG_API_HOST",
"POSTHOG_API_KEY",
"PRIVACY_URL",
"RATE_LIMITING_DISABLED",
"REDIS_URL",
@@ -203,7 +201,6 @@
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"PUBLIC_URL",
"TELEMETRY_DISABLED",
"TURNSTILE_SECRET_KEY",
"TURNSTILE_SITE_KEY",
"RECAPTCHA_SITE_KEY",