diff --git a/.cursor/rules/dashboard.mdc b/.cursor/rules/dashboard.mdc index eba9f1787f5..8b4745fe927 100644 --- a/.cursor/rules/dashboard.mdc +++ b/.cursor/rules/dashboard.mdc @@ -3,7 +3,6 @@ description: Rules for writing features in apps/dashboard globs: dashboard alwaysApply: false --- - # Reusable Core UI Components - Always import from the central UI library under `@/components/ui/*` – e.g. `import { Button } from "@/components/ui/button"`. @@ -101,3 +100,29 @@ Guidelines: - Keep `queryKey` stable and descriptive for cache hits. - Prefer API routes or server actions to keep tokens secret; the browser only sees relative paths. - Configure `staleTime` / `cacheTime` according to freshness requirements. + +# Analytics Event Reporting + +- **Add events intentionally** – only when they answer a concrete product/business question. +- **Event name**: human-readable ` ` phrase (e.g. `"contract deployed"`). +- **Reporting helper**: `report` (PascalCase); all live in `src/@/analytics/report.ts`. +- **Mandatory JSDoc**: explain *Why* the event exists and *Who* owns it (`@username`). +- **Typed properties**: accept a single `properties` object and pass it unchanged to `posthog.capture`. +- **Client-side only**: never import `posthog-js` in server components. +- **Housekeeping**: ping **#core-services** before renaming or removing an event. + +```ts +/** + * ### Why do we need to report this event? + * - Tracks number of contracts deployed + * + * ### Who is responsible for this event? + * @jnsdls + */ +export function reportContractDeployed(properties: { + address: string; + chainId: number; +}) { + posthog.capture("contract deployed", properties); +} +``` diff --git a/AGENTS.md b/AGENTS.md index 309316d55f6..e5831ca0394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,41 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de -- Configure staleTime / cacheTime based on freshness requirements (default ≥ 60 s). -- Keep tokens secret by calling internal API routes or server actions. + 6.5 Analytics Event Reporting + +- **When to create a new event** + -- Only add events that answer a clear product or business question. + -- Check `src/@/analytics/report.ts` first; avoid duplicates. + +- **Naming conventions** + -- **Event name**: human-readable phrase in the form ` ` (e.g. "contract deployed"). + -- **Reporting function**: `report` (PascalCase). + -- All reporting helpers currently live in the shared `report.ts` file. + +- **Boilerplate template** + -- Add a JSDoc header explaining **Why** the event exists and **Who** owns it (`@username`). + -- Accept a single typed `properties` object and forward it unchanged to `posthog.capture`. + -- Example: + +```ts +/** + * ### Why do we need to report this event? + * - Tracks number of contracts deployed + * + * ### Who is responsible for this event? + * @jnsdls + */ +export function reportContractDeployed(properties: { + address: string; + chainId: number; +}) { + posthog.capture("contract deployed", properties); +} +``` + +- **Client-side only**: never import `posthog-js` in server components. +- **Housekeeping**: Inform **#eng-core-services** before renaming or removing an existing event. + ⸻ 7. Performance & Bundle Size diff --git a/apps/dashboard/src/@/analytics/README.md b/apps/dashboard/src/@/analytics/README.md new file mode 100644 index 00000000000..74e766f6e33 --- /dev/null +++ b/apps/dashboard/src/@/analytics/README.md @@ -0,0 +1,67 @@ +# Analytics Guidelines + +This folder centralises the **PostHog** tracking logic for the dashboard app. +Most developers will only need to add or extend _event-reporting_ functions in `report.ts`. + +--- + +## 1. When to add an event +1. Ask yourself if the data will be **actionable**. Every event should have a clear product or business question it helps answer. +2. Check if a similar event already exists in `report.ts`. Avoid duplicates. + +--- + +## 2. Naming conventions +| Concept | Convention | Example | +|---------|------------|---------| +| **Event name** (string sent to PostHog) | Human-readable phrase formatted as ` ` | `"contract deployed"` | +| **Reporting function** | `report` (PascalCase) | `reportContractDeployed` | +| **File** | All event functions live in the shared `report.ts` file (for now) | — | + +> Keeping names predictable makes it easy to search both code and analytics. + +--- + +## 3. Boilerplate / template +Add a new function to `report.ts` following this pattern: + +```ts +/** + * ### Why do we need to report this event? + * - _Add bullet points explaining the product metrics/questions this event answers._ + * + * ### Who is responsible for this event? + * @your-github-handle + */ +export function reportExampleEvent(properties: { + /* Add typed properties here */ +}) { + posthog.capture("example event", { + /* Pass the same properties here */ + }); +} +``` + +Guidelines: +1. **Explain the "why".** The JSDoc block is mandatory so future contributors know the purpose. +2. **Type everything.** The `properties` object should be fully typed—this doubles as documentation. +3. **Client-side only.** `posthog-js` must never run on the server. Call these reporting helpers from client components, event handlers, etc. + +--- + +## 4. Editing or removing events +1. Update both the function and the PostHog event definition (if required). +2. Inform the core services team before removing or renaming an event. + +--- + +## 5. Identification & housekeeping (FYI) +Most devs can ignore this section, but for completeness: + +- `hooks/identify-account.ts` and `hooks/identify-team.ts` wrap `posthog.identify`/`group` calls. +- `resetAnalytics` clears identity state (used on logout). + +--- + +## 6. Need help? +Ping #eng-core-services in slack. diff --git a/apps/dashboard/src/@/analytics/report.ts b/apps/dashboard/src/@/analytics/report.ts new file mode 100644 index 00000000000..e94f62d60bb --- /dev/null +++ b/apps/dashboard/src/@/analytics/report.ts @@ -0,0 +1,150 @@ +import posthog from "posthog-js"; + +import type { Team } from "../api/team"; + +// ---------------------------- +// CONTRACTS +// ---------------------------- + +/** + * ### Why do we need to report this event? + * - To track the number of contracts deployed + * - To track the number of contracts deployed on each chain + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportContractDeployed(properties: { + address: string; + chainId: number; + publisher: string | undefined; + contractName: string | undefined; +}) { + posthog.capture("contract deployed", properties); +} + +/** + * ### Why do we need to report this event? + * - To track the number of contracts that failed to deploy + * - To track the error message of the failed contract deployment (so we can fix it / add workarounds) + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportContractDeployFailed(properties: { + errorMessage: string; +}) { + posthog.capture("contract deploy failed", properties); +} + +// ---------------------------- +// ONBOARDING (TEAM) +// ---------------------------- + +/** + * ### Why do we need to report this event? + * - To track the number of teams that enter the onboarding flow + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingStarted() { + posthog.capture("onboarding started"); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that select a paid plan during onboarding + * - To know **which** plan was selected + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingPlanSelected(properties: { + plan: Team["billingPlan"]; +}) { + posthog.capture("onboarding plan selected", properties); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that skip the plan-selection step during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingPlanSelectionSkipped() { + posthog.capture("onboarding plan selection skipped"); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that invite members during onboarding + * - To track **how many** members were invited + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersInvited(properties: { + count: number; +}) { + posthog.capture("onboarding members invited", { + count: properties.count, + }); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that skip inviting members during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersSkipped() { + posthog.capture("onboarding members skipped"); +} + +/** + * ### Why do we need to report this event? + * - To track how many teams click the upsell (upgrade) button on the member-invite step during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersUpsellButtonClicked() { + posthog.capture("onboarding members upsell clicked"); +} + +/** + * ### Why do we need to report this event? + * - To track which plan is selected from the members-step upsell during onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingMembersUpsellPlanSelected(properties: { + plan: Team["billingPlan"]; +}) { + posthog.capture("onboarding members upsell plan selected", properties); +} + +/** + * ### Why do we need to report this event? + * - To track the number of teams that completed onboarding + * + * ### Who is responsible for this event? + * @jnsdls + * + */ +export function reportOnboardingCompleted() { + posthog.capture("onboarding completed"); +} diff --git a/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/_components/plan-selector.tsx b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/_components/plan-selector.tsx index a677a5ad447..d179a873c6b 100644 --- a/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/_components/plan-selector.tsx +++ b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/_components/plan-selector.tsx @@ -1,11 +1,14 @@ "use client"; +import { + reportOnboardingPlanSelected, + reportOnboardingPlanSelectionSkipped, +} from "@/analytics/report"; import type { Team } from "@/api/team"; import { PricingCard } from "@/components/blocks/pricing-card"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import { useTrack } from "hooks/analytics/useTrack"; import Link from "next/link"; import { pollWithTimeout } from "utils/pollWithTimeout"; import { useStripeRedirectEvent } from "../../../../../(stripe)/stripe-redirect/stripeRedirectChannel"; @@ -14,7 +17,6 @@ export function PlanSelector(props: { team: Team; getTeam: () => Promise; }) { - const trackEvent = useTrack(); const router = useDashboardRouter(); useStripeRedirectEvent(async () => { @@ -25,12 +27,6 @@ export function PlanSelector(props: { const isNonFreePlan = team.billingPlan !== "free"; if (isNonFreePlan) { - trackEvent({ - category: "teamOnboarding", - action: "upgradePlan", - label: "success", - plan: team.billingPlan, - }); router.replace(`/get-started/team/${props.team.slug}/add-members`); } @@ -49,10 +45,7 @@ export function PlanSelector(props: { label: "Get Started", type: "checkout", onClick() { - trackEvent({ - category: "teamOnboarding", - action: "selectPlan", - label: "attempt", + reportOnboardingPlanSelected({ plan: "starter", }); }, @@ -71,10 +64,7 @@ export function PlanSelector(props: { label: "Get Started", type: "checkout", onClick() { - trackEvent({ - category: "teamOnboarding", - action: "selectPlan", - label: "attempt", + reportOnboardingPlanSelected({ plan: "growth", }); }, @@ -94,10 +84,7 @@ export function PlanSelector(props: { label: "Get started", type: "checkout", onClick() { - trackEvent({ - category: "teamOnboarding", - action: "selectPlan", - label: "attempt", + reportOnboardingPlanSelected({ plan: "scale", }); }, @@ -116,10 +103,7 @@ export function PlanSelector(props: { label: "Get started", type: "checkout", onClick() { - trackEvent({ - category: "teamOnboarding", - action: "selectPlan", - label: "attempt", + reportOnboardingPlanSelected({ plan: "pro", }); }, @@ -146,17 +130,13 @@ export function PlanSelector(props: { variant="link" className="self-center text-muted-foreground" asChild - onClick={() => { - trackEvent({ - category: "teamOnboarding", - action: "selectPlan", - label: "skip", - }); - }} > { + reportOnboardingPlanSelectionSkipped(); + }} > Skip picking a plan for now and upgrade later diff --git a/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/page.tsx b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/page.tsx index 96c19ed44ae..fbd5edb5869 100644 --- a/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/page.tsx +++ b/apps/dashboard/src/app/(app)/get-started/team/[team_slug]/select-plan/page.tsx @@ -17,11 +17,6 @@ export default async function Page(props: { notFound(); } - // const client = getClientThirdwebClient({ - // jwt: authToken, - // teamId: team.id, - // }); - async function getTeam() { "use server"; const resolvedTeam = await getTeamBySlug(params.team_slug); diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx index cd5a73be46d..9e195e93716 100644 --- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx +++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.stories.tsx @@ -62,9 +62,6 @@ function Story(props: { { - storybookLog("trackEvent", params); - }} getTeam={async () => { return teamStub("foo", props.plan); }} diff --git a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx index 9c9821399c6..aaf3fb6be73 100644 --- a/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx +++ b/apps/dashboard/src/app/(app)/login/onboarding/team-onboarding/InviteTeamMembers.tsx @@ -1,5 +1,11 @@ "use client"; +import { + reportOnboardingMembersInvited, + reportOnboardingMembersSkipped, + reportOnboardingMembersUpsellButtonClicked, + reportOnboardingMembersUpsellPlanSelected, +} from "@/analytics/report"; import type { Team } from "@/api/team"; import { PricingCard } from "@/components/blocks/pricing-card"; import { Spinner } from "@/components/ui/Spinner/Spinner"; @@ -14,7 +20,6 @@ import { } from "@/components/ui/sheet"; import { TabButtons } from "@/components/ui/tabs"; import { useDashboardRouter } from "@/lib/DashboardRouter"; -import type { TrackingParams } from "hooks/analytics/useTrack"; import { ArrowRightIcon, CircleArrowUpIcon } from "lucide-react"; import { useState, useTransition } from "react"; import type { ThirdwebClient } from "thirdweb"; @@ -30,14 +35,13 @@ export function InviteTeamMembersUI(props: { inviteTeamMembers: InviteTeamMembersFn; onComplete: () => void; getTeam: () => Promise; - trackEvent: (params: TrackingParams) => void; client: ThirdwebClient; }) { const [showPlanModal, setShowPlanModal] = useState(false); const [isPending, startTransition] = useTransition(); const router = useDashboardRouter(); const [isPollingTeam, setIsPollingTeam] = useState(false); - const [hasSentInvites, setHasSentInvites] = useState(false); + const [successCount, setSuccessCount] = useState(0); const showSpinner = isPollingTeam || isPending; @@ -52,15 +56,6 @@ export function InviteTeamMembersUI(props: { const isNonFreePlan = team.billingPlan !== "free" && team.billingPlan !== "starter"; - if (isNonFreePlan) { - props.trackEvent({ - category: "teamOnboarding", - action: "upgradePlan", - label: "success", - plan: team.billingPlan, - }); - } - return isNonFreePlan; }, timeoutMs: 5000, @@ -73,6 +68,8 @@ export function InviteTeamMembersUI(props: { }); }); + const hasSentInvites = successCount > 0; + return (
@@ -80,7 +77,6 @@ export function InviteTeamMembersUI(props: { @@ -91,7 +87,9 @@ export function InviteTeamMembersUI(props: { inviteTeamMembers={props.inviteTeamMembers} team={props.team} userHasEditPermission={true} - onInviteSuccess={() => setHasSentInvites(true)} + onInviteSuccess={(count) => + setSuccessCount((prevCount) => prevCount + count) + } shouldHideInviteButton={hasSentInvites} client={props.client} // its a new team, there's no recommended members @@ -106,11 +104,7 @@ export function InviteTeamMembersUI(props: { className="gap-2" onClick={() => { setShowPlanModal(true); - props.trackEvent({ - category: "teamOnboarding", - action: "upgradePlan", - label: "openModal", - }); + reportOnboardingMembersUpsellButtonClicked(); }} > @@ -121,11 +115,11 @@ export function InviteTeamMembersUI(props: {