Compare commits

...

2 Commits

Author SHA1 Message Date
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
50 changed files with 934 additions and 728 deletions

View File

@@ -1,8 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors"; 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 { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
return ( return (
<div className="flex-1 bg-slate-50"> <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 /> <ToasterClient />
{children} {children}
</div> </div>

View File

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

View File

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

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, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { COLOR_DEFAULTS } from "@/lib/styling/constants"; 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 monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isLimitReached) { // Limit check completed
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`);
}
}
return isLimitReached; return isLimitReached;
}; };
@@ -111,10 +95,7 @@ export const GET = withV1ApiWrapper({
} }
if (!environment.appSetupCompleted) { if (!environment.appSetupCompleted) {
await Promise.all([ await updateEnvironment(environment.id, { appSetupCompleted: true });
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
} }
// check organization subscriptions and response limits // 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 { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display"; import { createDisplay } from "./lib/display";
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
try { try {
const response = await createDisplay(inputValidation.data); const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return { return {
response: responses.successResponse(response, true), 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 { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache"; import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data"; import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState"; import { getEnvironmentState } from "./environmentState";
// Mock dependencies // Mock dependencies
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/cache", () => ({ vi.mock("@/lib/cache", () => ({
cache: { cache: {
withCache: vi.fn(), withCache: vi.fn(),
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key", RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true, IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true, IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key", ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
})); }));
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
expect(result.data).toEqual(expectedData); expect(result.data).toEqual(expectedData);
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId); expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled(); expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
test("should throw ResourceNotFoundError if environment not found", async () => { test("should throw ResourceNotFoundError if environment not found", async () => {
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
where: { id: environmentId }, where: { id: environmentId },
data: { appSetupCompleted: true }, data: { appSetupCompleted: true },
}); });
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.data).toBeDefined(); expect(result.data).toBeDefined();
}); });
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
expect(result.data.surveys).toEqual([]); expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); 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 () => { 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(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); 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 () => { 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) // Should return surveys even with high count since limit is null (unlimited)
expect(result.data.surveys).toEqual(mockSurveys); expect(result.data.surveys).toEqual(mockSurveys);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
test("should propagate database update errors", async () => { test("should propagate database update errors", async () => {
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error"); 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 () => { test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);

View File

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

View File

@@ -9,7 +9,6 @@ import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { createResponse, createResponseWithQuotaEvaluation } from "./response"; import { createResponse, createResponseWithQuotaEvaluation } from "./response";
@@ -28,18 +27,10 @@ vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(), getOrganizationByEnvironmentId: vi.fn(),
})); }));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({ vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc), calculateTtcTotal: vi.fn((ttc) => ttc),
})); }));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(), validateInputs: vi.fn(),
})); }));
@@ -145,26 +136,6 @@ describe("createResponse", () => {
await createResponse(mockResponseInput, prisma); await createResponse(mockResponseInput, prisma);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); 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 () => { test("should throw ResourceNotFoundError if organization not found", async () => {
@@ -186,20 +157,6 @@ describe("createResponse", () => {
vi.mocked(prisma.response.create).mockRejectedValue(genericError); vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(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", () => { describe("createResponseWithQuotaEvaluation", () => {

View File

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

View File

@@ -8,7 +8,6 @@ import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
@@ -96,9 +95,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
// Mock dependencies // Mock dependencies
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, 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", ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id", GITHUB_ID: "mock-github-id",
@@ -118,10 +114,8 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
})); }));
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/service"); vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils"); vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -234,10 +228,9 @@ describe("Response Lib Tests", () => {
await createResponse(mockResponseInput, mockTx); await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); 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 limit = 100;
const mockOrgWithBilling = { const mockOrgWithBilling = {
...mockOrganization, ...mockOrganization,
@@ -251,32 +244,6 @@ describe("Response Lib Tests", () => {
await createResponse(mockResponseInput, mockTx); await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); 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 { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils"; import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
import { RESPONSES_PER_PAGE } from "@/lib/constants"; import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContactByUserId } from "./contact"; import { getContactByUserId } from "./contact";
@@ -93,7 +91,6 @@ export const createResponse = async (
tx?: Prisma.TransactionClient tx?: Prisma.TransactionClient
): Promise<TResponse> => { ): Promise<TResponse> => {
validateInputs([responseInput, ZResponseInput]); validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
const { environmentId, userId, finished, ttc: initialTtc } = responseInput; const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
@@ -131,8 +128,6 @@ export const createResponse = async (
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
return response; return response;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { 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 { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display"; import { createDisplay } from "./lib/display";
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
try { try {
const response = await createDisplay(inputValidation.data); const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true); return responses.successResponse(response, true);
} catch (error) { } catch (error) {
if (error instanceof ResourceNotFoundError) { if (error instanceof ResourceNotFoundError) {

View File

@@ -12,9 +12,7 @@ import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service"; import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
import { getContact } from "./contact"; import { getContact } from "./contact";
@@ -49,9 +47,7 @@ vi.mock("@/lib/constants", () => ({
})); }));
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/utils"); vi.mock("@/lib/response/utils");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@/modules/ee/quotas/lib/evaluation-service"); vi.mock("@/modules/ee/quotas/lib/evaluation-service");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
@@ -166,9 +162,7 @@ describe("createResponse V2", () => {
...ttc, ...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0), _total: Object.values(ttc).reduce((a, b) => a + b, 0),
})); }));
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
vi.mocked(evaluateResponseQuotas).mockResolvedValue({ vi.mocked(evaluateResponseQuotas).mockResolvedValue({
shouldEndSurvey: false, shouldEndSurvey: false,
quotaFull: null, quotaFull: null,
@@ -183,26 +177,6 @@ describe("createResponse V2", () => {
mockIsFormbricksCloud = true; mockIsFormbricksCloud = true;
await createResponse(mockResponseInput, mockTx); await createResponse(mockResponseInput, mockTx);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); 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 () => { test("should throw ResourceNotFoundError if organization not found", async () => {
@@ -225,20 +199,6 @@ describe("createResponse V2", () => {
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError); 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 () => { test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId }; const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = { const prismaResponseWithTags = {

View File

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

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 INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY); 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_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY; export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_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().optional()
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"), : z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(), PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
POSTHOG_API_KEY: z.string().optional(),
PRIVACY_URL: z PRIVACY_URL: z
.string() .string()
.url() .url()
@@ -103,7 +101,6 @@ export const env = createEnv({
} }
) )
.optional(), .optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z TERMS_URL: z
.string() .string()
.url() .url()
@@ -172,8 +169,6 @@ export const env = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME, MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN, 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, OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID, INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_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_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
PUBLIC_URL: process.env.PUBLIC_URL, PUBLIC_URL: process.env.PUBLIC_URL,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY, TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY, TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY, RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,

View File

@@ -17,7 +17,6 @@ import {
} from "@formbricks/types/environment"; } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "../organization/service"; import { getOrganizationsByUserId } from "../organization/service";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { getUserProjects } from "../project/service"; import { getUserProjects } from "../project/service";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -173,10 +172,6 @@ export const createEnvironment = async (
}, },
}); });
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
environmentType: environment.type,
});
return environment; return environment;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { 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, getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses, subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { evaluateLogic } from "@/lib/surveyLogic/utils"; import { evaluateLogic } from "@/lib/surveyLogic/utils";
import { import {
mockActionClass, mockActionClass,
@@ -44,11 +43,6 @@ vi.mock("@/lib/organization/service", () => ({
subscribeOrganizationMembersToSurveyResponses: vi.fn(), subscribeOrganizationMembersToSurveyResponses: vi.fn(),
})); }));
// Mock posthogServer
vi.mock("@/lib/posthogServer", () => ({
capturePosthogEnvironmentEvent: vi.fn(),
}));
// Mock actionClass service // Mock actionClass service
vi.mock("@/lib/actionClass/service", () => ({ vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(), getActionClasses: vi.fn(),
@@ -646,7 +640,6 @@ describe("Tests for createSurvey", () => {
expect(prisma.survey.create).toHaveBeenCalled(); expect(prisma.survey.create).toHaveBeenCalled();
expect(result.name).toEqual(mockSurveyOutput.name); expect(result.name).toEqual(mockSurveyOutput.name);
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled(); expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
}); });
test("creates a private segment for app surveys", async () => { test("creates a private segment for app surveys", async () => {

View File

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

@@ -1,13 +1,10 @@
import "server-only"; import "server-only";
import { Prisma, Response } from "@prisma/client"; import { Prisma, Response } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { captureTelemetry } from "@/lib/telemetry";
import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact"; import { getContactByUserId } from "@/modules/api/v2/management/responses/lib/contact";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
@@ -51,8 +48,6 @@ export const createResponse = async (
responseInput: TResponseInput, responseInput: TResponseInput,
tx?: Prisma.TransactionClient tx?: Prisma.TransactionClient
): Promise<Result<Response, ApiErrorResponseV2>> => { ): Promise<Result<Response, ApiErrorResponseV2>> => {
captureTelemetry("response created");
const { const {
surveyId, surveyId,
displayId, displayId,
@@ -126,7 +121,6 @@ export const createResponse = async (
if (!billing.ok) { if (!billing.ok) {
return err(billing.error as ApiErrorResponseV2); return err(billing.error as ApiErrorResponseV2);
} }
const billingData = billing.data;
const prismaClient = tx ?? prisma; const prismaClient = tx ?? prisma;
@@ -140,26 +134,7 @@ export const createResponse = async (
return err(responsesCountResult.error as ApiErrorResponseV2); return err(responsesCountResult.error as ApiErrorResponseV2);
} }
const responsesCount = responsesCountResult.data; // Limit check completed
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");
}
}
} }
return ok(response); return ok(response);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/database/zod/users"; import { TUser } from "@formbricks/database/zod/users";
import { Result, err, ok } from "@formbricks/types/error-handlers"; 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 { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils";
import { import {
TGetUsersFilter, TGetUsersFilter,
@@ -73,8 +72,6 @@ export const createUser = async (
userInput: TUserInput, userInput: TUserInput,
organizationId organizationId
): Promise<Result<TUser, ApiErrorResponseV2>> => { ): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user created");
const { name, email, role, teams, isActive } = userInput; const { name, email, role, teams, isActive } = userInput;
try { try {
@@ -150,8 +147,6 @@ export const updateUser = async (
userInput: TUserInputPatch, userInput: TUserInputPatch,
organizationId: string organizationId: string
): Promise<Result<TUser, ApiErrorResponseV2>> => { ): Promise<Result<TUser, ApiErrorResponseV2>> => {
captureTelemetry("user updated");
const { name, email, role, teams, isActive } = userInput; const { name, email, role, teams, isActive } = userInput;
let existingTeams: string[] = []; let existingTeams: string[] = [];
let newTeams; 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 { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite"; import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team"; 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 { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -46,21 +46,15 @@ const ZCreateUserAction = z.object({
), ),
}); });
async function verifyTurnstileIfConfigured( async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
turnstileToken: string | undefined,
email: string,
name: string
): Promise<void> {
if (!IS_TURNSTILE_CONFIGURED) return; if (!IS_TURNSTILE_CONFIGURED) return;
if (!turnstileToken || !TURNSTILE_SECRET_KEY) { if (!turnstileToken || !TURNSTILE_SECRET_KEY) {
captureFailedSignup(email, name);
throw new UnknownError("Server configuration error"); throw new UnknownError("Server configuration error");
} }
const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken); const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken);
if (!isHuman) { if (!isHuman) {
captureFailedSignup(email, name);
throw new UnknownError("reCAPTCHA verification failed"); throw new UnknownError("reCAPTCHA verification failed");
} }
} }
@@ -180,7 +174,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
"user", "user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => { async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.signup); await applyIPRateLimit(rateLimitConfigs.auth.signup);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name); await verifyTurnstileIfConfigured(parsedInput.turnstileToken);
const hashedPassword = await hashPassword(parsedInput.password); const hashedPassword = await hashPassword(parsedInput.password);
const { user, userAlreadyExisted } = await createUserSafely( const { user, userAlreadyExisted } = await createUserSafely(

View File

@@ -13,7 +13,6 @@ import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createUserAction } from "@/modules/auth/signup/actions"; import { createUserAction } from "@/modules/auth/signup/actions";
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links"; 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 { SSOOptions } from "@/modules/ee/sso/components/sso-options";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
@@ -236,7 +235,6 @@ export const SignupForm = ({
onError={() => { onError={() => {
setTurnstileToken(undefined); setTurnstileToken(undefined);
toast.error(t("auth.signup.captcha_failed")); toast.error(t("auth.signup.captcha_failed"));
captureFailedSignup(form.getValues("email"), form.getValues("name"));
}} }}
/> />
)} )}

View File

@@ -1,6 +1,5 @@
import posthog from "posthog-js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { captureFailedSignup, verifyTurnstileToken } from "./utils"; import { verifyTurnstileToken } from "./utils";
beforeEach(() => { beforeEach(() => {
global.fetch = vi.fn(); global.fetch = vi.fn();
@@ -62,18 +61,3 @@ describe("verifyTurnstileToken", () => {
expect(result).toBe(false); 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> => { export const verifyTurnstileToken = async (secretKey: string, token: string): Promise<boolean> => {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -29,10 +27,3 @@ export const verifyTurnstileToken = async (secretKey: string, token: string): Pr
clearTimeout(timeoutId); 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, ITEMS_PER_PAGE: 2,
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!", ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
IS_PRODUCTION: false, IS_PRODUCTION: false,
IS_POSTHOG_CONFIGURED: false,
POSTHOG_API_HOST: "test-posthog-host",
POSTHOG_API_KEY: "test-posthog-key",
})); }));
const environmentId = "cm123456789012345678901237"; const environmentId = "cm123456789012345678901237";

View File

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

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

View File

@@ -7,7 +7,6 @@ import {
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses, subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils"; import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger"; import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class"; import { getActionClasses } from "@/modules/survey/lib/action-class";
@@ -122,11 +121,6 @@ export const createSurvey = async (
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id); await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
} }
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
surveyId: survey.id,
surveyType: survey.type,
});
return transformedSurvey; return transformedSurvey;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { 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 { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
interface EnvironmentIdBaseLayoutProps { interface EnvironmentIdBaseLayoutProps {
children: React.ReactNode; children: React.ReactNode;
environmentId: string;
session: Session;
user: TUser;
organization: TOrganization;
} }
export const EnvironmentIdBaseLayout = async ({ export const EnvironmentIdBaseLayout = async ({ children }: EnvironmentIdBaseLayoutProps) => {
children,
environmentId,
session,
user,
organization,
}: EnvironmentIdBaseLayoutProps) => {
return ( return (
<ResponseFilterProvider> <ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient /> <ToasterClient />
{children} {children}
</ResponseFilterProvider> </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", "nodemailer": "7.0.9",
"otplib": "12.0.1", "otplib": "12.0.1",
"papaparse": "5.5.2", "papaparse": "5.5.2",
"posthog-js": "1.240.0",
"posthog-node": "5.9.2",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qr-code-styling": "1.9.2", "qr-code-styling": "1.9.2",
"qrcode": "1.5.4", "qrcode": "1.5.4",

View File

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

View File

@@ -186,9 +186,6 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false, 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", ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id", 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) | | | GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | | | STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | | | STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b | | DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | | | DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | | | OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |

View File

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