feat: Add PostHog

This commit is contained in:
Harsh Bhat
2026-03-11 10:54:30 +05:30
parent 1e19cca7d9
commit 552ea2822d
18 changed files with 441 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ import {
} from "date-fns";
import { TFunction } from "i18next";
import { Loader2 } from "lucide-react";
import posthog from "posthog-js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -252,6 +253,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
});
if (responsesDownloadUrlResponse?.data) {
posthog.capture("responses_exported", {
survey_id: survey.id,
survey_name: survey.name,
file_type: fileType,
filter_type: filter,
});
downloadResponsesFile(
responsesDownloadUrlResponse.data.fileName,
responsesDownloadUrlResponse.data.fileContents,

View File

@@ -8,6 +8,7 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getPostHogClient } from "@/lib/posthog-server";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
@@ -173,6 +174,21 @@ export const POST = async (request: Request, context: Context): Promise<Response
surveyId: responseData.surveyId,
response: responseData,
});
try {
const posthogServer = getPostHogClient();
posthogServer.capture({
distinctId: environmentId,
event: "survey_response_finished",
properties: {
survey_id: responseData.surveyId,
response_id: responseData.id,
environment_id: environmentId,
},
});
} catch {
// non-critical, don't block the response
}
}
const quotaObj = createQuotaFullObject(quotaFull);

View File

@@ -0,0 +1,9 @@
import posthog from "posthog-js";
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: "/ingest",
ui_host: "https://eu.posthog.com",
defaults: "2026-01-30",
capture_exceptions: true,
debug: process.env.NODE_ENV === "development",
});

View File

@@ -0,0 +1,20 @@
import { PostHog } from "posthog-node";
let posthogClient: PostHog | null = null;
export function getPostHogClient(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
});
}
return posthogClient;
}
export async function shutdownPostHog(): Promise<void> {
if (posthogClient) {
await posthogClient.shutdown();
}
}

View File

@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
@@ -119,9 +120,15 @@ export const LoginForm = ({
}
if (!signInResponse?.error) {
posthog.identify(data.email.toLowerCase(), { email: data.email.toLowerCase() });
posthog.capture("user_logged_in", {
email: data.email.toLowerCase(),
method: "email",
});
router.push(searchParams?.get("callbackUrl") ?? "/");
}
} catch (error) {
posthog.captureException(error);
toast.error(error.toString());
}
};

View File

@@ -3,6 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
@@ -128,6 +129,14 @@ export const SignupForm = ({
: `/auth/verification-requested?token=${token}`;
if (createUserResponse?.data) {
posthog.identify(data.email, { name: data.name, email: data.email });
posthog.capture("user_signed_up", {
name: data.name,
email: data.email,
is_formbricks_cloud: isFormbricksCloud,
has_invite_token: !!inviteToken,
email_verification_disabled: emailVerificationDisabled,
});
router.push(url);
if (!emailTokenActionResponse?.data) {
@@ -149,6 +158,7 @@ export const SignupForm = ({
toast.error(errorMessage);
}
} catch (e: any) {
posthog.captureException(e);
toast.error(e.message);
}
};

View File

@@ -4,6 +4,7 @@ import { PipelineTriggers, Webhook } from "@prisma/client";
import clsx from "clsx";
import { Webhook as WebhookIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
@@ -153,6 +154,11 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
webhookSecret: testResult.secret,
});
if (createWebhookActionResult?.data) {
posthog.capture("webhook_created", {
environment_id: environmentId,
webhook_triggers: selectedTriggers,
survey_count: selectedAllSurveys ? "all" : selectedSurveys.length,
});
router.refresh();
setCreatedWebhook(createWebhookActionResult.data);
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));

View File

@@ -2,6 +2,7 @@
import { PlusCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
@@ -45,6 +46,10 @@ export const CreateOrganizationModal = ({ open, setOpen }: CreateOrganizationMod
setLoading(true);
const createOrganizationResponse = await createOrganizationAction({ organizationName: data.name });
if (createOrganizationResponse?.data) {
posthog.capture("organization_created", {
organization_id: createOrganizationResponse.data.id,
organization_name: data.name,
});
toast.success(t("environments.settings.general.organization_created_successfully"));
router.push(`/organizations/${createOrganizationResponse.data.id}`);
setOpen(false);

View File

@@ -2,6 +2,7 @@
import { ApiKeyPermission } from "@prisma/client";
import { FilesIcon, TrashIcon } from "lucide-react";
import posthog from "posthog-js";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -80,6 +81,10 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
const updatedApiKeys = [...apiKeysLocal, createApiKeyResponse.data];
setApiKeysLocal(updatedApiKeys);
setIsLoading(false);
posthog.capture("api_key_created", {
organization_id: organizationId,
api_key_label: data.label,
});
toast.success(t("environments.workspace.api_keys.api_key_created"));
} else {
setIsLoading(false);

View File

@@ -2,6 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
@@ -36,6 +37,11 @@ export const CreateOrganization = () => {
setIsSubmitting(true);
const createOrganizationResponse = await createOrganizationAction({ organizationName });
if (createOrganizationResponse?.data) {
posthog.capture("organization_created", {
organization_id: createOrganizationResponse.data.id,
organization_name: organizationName,
source: "setup",
});
router.push(`/setup/organization/${createOrganizationResponse.data.id}/invite`);
}
} catch (error) {

View File

@@ -2,6 +2,7 @@
import { Project } from "@prisma/client";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -64,6 +65,12 @@ export const TemplateList = ({
});
if (createSurveyResponse?.data) {
const eventName = surveyType === "link" ? "link_survey_created" : "in_app_survey_created";
posthog.capture(eventName, {
survey_id: createSurveyResponse.data.id,
template_name: activeTemplate.name,
environment_id: environmentId,
});
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit`);
} else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse);

View File

@@ -4,6 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Environment } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon, LinkIcon, MonitorIcon } from "lucide-react";
import posthog from "posthog-js";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSegment } from "@formbricks/types/segment";
@@ -42,6 +43,12 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
endings: endingsTemp,
}));
posthog.capture("survey_type_selected", {
survey_id: localSurvey.id,
survey_type: type,
previous_type: localSurvey.type,
});
// if the type is "app" and the local survey does not already have a segment, we create a new temporary segment
if (type === "app" && !localSurvey.segment) {
const tempSegment: TSegment = {

View File

@@ -4,6 +4,7 @@ import { Project } from "@prisma/client";
import { isEqual } from "lodash";
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -391,6 +392,12 @@ export const SurveyMenuBar = ({
if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data);
toast.success(t("environments.surveys.edit.changes_saved"));
posthog.capture("survey_saved", {
survey_id: localSurvey.id,
survey_name: localSurvey.name,
survey_type: localSurvey.type,
survey_status: localSurvey.status,
});
// Set flag to prevent beforeunload warning during router.refresh()
isSuccessfullySavedRef.current = true;
router.refresh();
@@ -402,6 +409,7 @@ export const SurveyMenuBar = ({
return true;
} catch (e) {
posthog.captureException(e);
console.error(e);
setIsSurveySaving(false);
toast.error(t("environments.surveys.edit.error_saving_changes"));
@@ -450,10 +458,16 @@ export const SurveyMenuBar = ({
}
setIsSurveyPublishing(false);
posthog.capture("survey_published", {
survey_id: localSurvey.id,
survey_name: localSurvey.name,
survey_type: localSurvey.type,
});
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
} catch (error) {
posthog.captureException(error);
console.error(error);
toast.error(t("environments.surveys.edit.error_publishing_survey"));
setIsSurveyPublishing(false);

View File

@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -74,6 +75,12 @@ export const SurveyDropDownMenu = ({
toast.error(getFormattedErrorMessage(result));
return;
}
posthog.capture("survey_deleted", {
survey_id: surveyId,
survey_name: survey.name,
survey_type: survey.type,
survey_status: survey.status,
});
deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {
@@ -90,6 +97,11 @@ export const SurveyDropDownMenu = ({
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
posthog.capture("survey_link_copied", {
survey_id: survey.id,
survey_name: survey.name,
survey_type: survey.type,
});
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
logger.error(error);
@@ -112,6 +124,13 @@ export const SurveyDropDownMenu = ({
if (transformedDuplicatedSurvey?.data) {
onSurveysCopied?.();
}
posthog.capture("survey_duplicated", {
original_survey_id: surveyId,
new_survey_id: duplicatedSurveyResponse.data.id,
survey_name: survey.name,
survey_type: survey.type,
environment_id: environmentId,
});
toast.success(t("environments.surveys.survey_duplicated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);

View File

@@ -404,8 +404,17 @@ const nextConfig = {
},
];
},
skipTrailingSlashRedirect: true,
async rewrites() {
return [
{
source: "/ingest/static/:path*",
destination: "https://eu-assets.i.posthog.com/static/:path*",
},
{
source: "/ingest/:path*",
destination: "https://eu.i.posthog.com/:path*",
},
{
source: "/api/packages/website",
destination: "/js/formbricks.umd.cjs",

View File

@@ -102,6 +102,8 @@
"nodemailer": "8.0.1",
"otplib": "12.0.1",
"papaparse": "5.5.3",
"posthog-js": "1.360.0",
"posthog-node": "5.28.0",
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",

View File

@@ -0,0 +1,107 @@
# PostHog Setup Report
## Overview
PostHog analytics has been integrated into the Formbricks web app (`apps/web`) using the Next.js App Router approach with `instrumentation-client.ts`.
## Configuration
| Setting | Value |
|---|---|
| PostHog Host | `https://eu.i.posthog.com` |
| Reverse Proxy | `/ingest/*``https://eu.i.posthog.com/*` |
| Client Init | `apps/web/instrumentation-client.ts` |
| Server Client | `apps/web/lib/posthog-server.ts` |
## Environment Variables
Added to `apps/web/.env`:
```
NEXT_PUBLIC_POSTHOG_KEY=phc_ies97ZFTIL3f8T1sQOTCWQycBzqqPvkPcOkpxYJ1sOA
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
```
## Packages Installed
- `posthog-js` — client-side tracking
- `posthog-node` — server-side tracking
## Files Modified
### New Files
- **`apps/web/instrumentation-client.ts`** — Client-side PostHog initialization (Next.js 15.3+ App Router pattern)
- **`apps/web/lib/posthog-server.ts`** — Singleton server-side PostHog client
### Modified Files
- **`apps/web/next.config.mjs`** — Added `skipTrailingSlashRedirect: true` and `/ingest/*` reverse proxy rewrites
- **`apps/web/modules/auth/signup/components/signup-form.tsx`** — Added `user_signed_up` event + `posthog.identify()`
- **`apps/web/modules/auth/login/components/login-form.tsx`** — Added `user_logged_in` event + `posthog.identify()`
- **`apps/web/modules/survey/editor/components/survey-menu-bar.tsx`** — Added `survey_saved` and `survey_published` events
- **`apps/web/modules/survey/list/components/survey-dropdown-menu.tsx`** — Added `survey_deleted`, `survey_duplicated`, and `survey_link_copied` events
- **`apps/web/modules/survey/components/template-list/index.tsx`** — Added `link_survey_created` / `in_app_survey_created` events
- **`apps/web/modules/survey/editor/components/how-to-send-card.tsx`** — Added `survey_type_selected` event
- **`apps/web/modules/organization/settings/api-keys/components/edit-api-keys.tsx`** — Added `api_key_created` event
- **`apps/web/modules/organization/components/CreateOrganizationModal/index.tsx`** — Added `organization_created` event
- **`apps/web/modules/setup/organization/create/components/create-organization.tsx`** — Added `organization_created` event (setup flow)
- **`apps/web/modules/integrations/webhooks/components/add-webhook-modal.tsx`** — Added `webhook_created` event
- **`apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx`** — Added `responses_exported` event
- **`apps/web/app/api/v2/client/[environmentId]/responses/route.ts`** — Added server-side `survey_response_finished` event
## Events Tracked
| Event | Location | Properties |
|---|---|---|
| `user_signed_up` | `signup-form.tsx` | `name`, `email`, `is_formbricks_cloud`, `has_invite_token`, `email_verification_disabled` |
| `user_logged_in` | `login-form.tsx` | `email`, `method` |
| `link_survey_created` | `template-list/index.tsx` | `survey_id`, `template_name`, `environment_id` |
| `in_app_survey_created` | `template-list/index.tsx` | `survey_id`, `template_name`, `environment_id` |
| `survey_type_selected` | `how-to-send-card.tsx` | `survey_id`, `survey_type`, `previous_type` |
| `survey_saved` | `survey-menu-bar.tsx` | `survey_id`, `survey_name`, `survey_type`, `survey_status` |
| `survey_published` | `survey-menu-bar.tsx` | `survey_id`, `survey_name`, `survey_type` |
| `survey_deleted` | `survey-dropdown-menu.tsx` | `survey_id`, `survey_name`, `survey_type`, `survey_status` |
| `survey_duplicated` | `survey-dropdown-menu.tsx` | `original_survey_id`, `new_survey_id`, `survey_name`, `survey_type`, `environment_id` |
| `survey_link_copied` | `survey-dropdown-menu.tsx` | `survey_id`, `survey_name`, `survey_type` |
| `survey_response_finished` | `responses/route.ts` (server) | `survey_id`, `response_id`, `environment_id` |
| `organization_created` | `CreateOrganizationModal`, `create-organization.tsx` | `organization_id`, `organization_name` |
| `api_key_created` | `edit-api-keys.tsx` | `organization_id`, `api_key_label` |
| `webhook_created` | `add-webhook-modal.tsx` | `environment_id`, `webhook_triggers`, `survey_count` |
| `responses_exported` | `CustomFilter.tsx` | `survey_id`, `survey_name`, `file_type`, `filter_type` |
## PostHog Dashboards
### 1. Analytics Basics (ID: 563010)
**URL:** https://eu.posthog.com/project/138028/dashboard/563010
| Insight | Type | URL |
|---|---|---|
| User Signups & Logins Over Time | Trends (line) | https://eu.posthog.com/project/138028/insights/vRTZYMqz |
| Signup to First Survey Published Funnel | Funnel | https://eu.posthog.com/project/138028/insights/SXvzJPQz |
| Survey Lifecycle Events | Trends (line) — includes `link_survey_created` + `in_app_survey_created` | https://eu.posthog.com/project/138028/insights/8NJpApcm |
| Survey Engagement Actions | Trends (bar) | https://eu.posthog.com/project/138028/insights/cQRrcfBq |
| Full User Journey Funnel | Funnel (unordered) — includes both survey creation events | https://eu.posthog.com/project/138028/insights/kFjH9atY |
| Total Surveys Created | Trends (bar) — combines `link_survey_created` + `in_app_survey_created` | https://eu.posthog.com/project/138028/insights/rAPBfnmR |
### 2. Survey Creation & Types (ID: 563022)
**URL:** https://eu.posthog.com/project/138028/dashboard/563022
| Insight | Type | URL |
|---|---|---|
| Link vs In-App Survey Creation | Trends (line) | https://eu.posthog.com/project/138028/insights/QL0LkWoI |
| Survey Type Selection Distribution | Trends (pie) | https://eu.posthog.com/project/138028/insights/KPSUe2IF |
| Survey Creation to Response Funnel | Funnel (unordered) — includes both `link_survey_created` + `in_app_survey_created` | https://eu.posthog.com/project/138028/insights/9UPWbBAf |
| Total Surveys Created | Trends (bar) — combines both events | https://eu.posthog.com/project/138028/insights/rAPBfnmR |
### 3. Integrations & API (ID: 563021)
**URL:** https://eu.posthog.com/project/138028/dashboard/563021
| Insight | Type | URL |
|---|---|---|
| Integrations & API Usage | Trends (line) | https://eu.posthog.com/project/138028/insights/ATYgsDY2 |
| Organizations & Responses Over Time | Trends (line) | https://eu.posthog.com/project/138028/insights/DZVTeqql |
| Response Export Format Distribution | Trends (bar value) | https://eu.posthog.com/project/138028/insights/00bQNehy |