diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 4d7dcdd3..835123e0 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -7,6 +7,7 @@ import os from 'os'; import { Redis } from 'ioredis'; import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js"; import { BackendError, BackendException } from "@sourcebot/error"; +import { captureEvent } from "./posthog.js"; interface IConnectionManager { scheduleConnectionSync: (connection: Connection) => Promise; @@ -22,6 +23,10 @@ type JobPayload = { config: ConnectionConfig, }; +type JobResult = { + repoCount: number +} + export class ConnectionManager implements IConnectionManager { private worker: Worker; private queue: Queue; @@ -217,10 +222,14 @@ export class ConnectionManager implements IConnectionManager { const totalUpsertDuration = performance.now() - totalUpsertStart; this.logger.info(`Upserted ${repoData.length} repos in ${totalUpsertDuration}ms`); }); + + return { + repoCount: repoData.length, + }; } - private async onSyncJobCompleted(job: Job) { + private async onSyncJobCompleted(job: Job, result: JobResult) { this.logger.info(`Connection sync job ${job.id} completed`); const { connectionId } = job.data; @@ -233,14 +242,24 @@ export class ConnectionManager implements IConnectionManager { syncedAt: new Date() } }) + + captureEvent('backend_connection_sync_job_completed', { + connectionId: connectionId, + repoCount: result.repoCount, + }); } private async onSyncJobFailed(job: Job | undefined, err: unknown) { this.logger.info(`Connection sync job failed with error: ${err}`); if (job) { + const { connectionId } = job.data; + + captureEvent('backend_connection_sync_job_failed', { + connectionId: connectionId, + error: err instanceof BackendException ? err.code : 'UNKNOWN', + }); // We may have pushed some metadata during the execution of the job, so we make sure to not overwrite the metadata here - const { connectionId } = job.data; let syncStatusMetadata: Record = (await this.db.connection.findUnique({ where: { id: connectionId }, select: { syncStatusMetadata: true } diff --git a/packages/backend/src/posthogEvents.ts b/packages/backend/src/posthogEvents.ts index 25386b6d..d80d47d5 100644 --- a/packages/backend/src/posthogEvents.ts +++ b/packages/backend/src/posthogEvents.ts @@ -5,17 +5,20 @@ export type PosthogEventMap = { vcs: string; codeHost?: string; }, - repo_synced: { - vcs: string; - codeHost?: string; - fetchDuration_s?: number; - cloneDuration_s?: number; - indexDuration_s?: number; - }, repo_deleted: { vcs: string; codeHost?: string; - } + }, + ////////////////////////////////////////////////////////////////// + backend_connection_sync_job_failed: { + connectionId: number, + error: string, + }, + backend_connection_sync_job_completed: { + connectionId: number, + repoCount: number, + }, + ////////////////////////////////////////////////////////////////// } export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index 7d39d71d..fe9e2a66 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -10,6 +10,7 @@ import { cloneRepository, fetchRepository } from "./git.js"; import { existsSync, rmSync, readdirSync } from 'fs'; import { indexGitRepository } from "./zoekt.js"; import os from 'os'; +import { BackendException } from "@sourcebot/error"; interface IRepoManager { blockingPollLoop: () => void; @@ -308,14 +309,6 @@ export class RepoManager implements IRepoManager { indexDuration_s = stats!.indexDuration_s; fetchDuration_s = stats!.fetchDuration_s; cloneDuration_s = stats!.cloneDuration_s; - - captureEvent('repo_synced', { - vcs: 'git', - codeHost: repo.external_codeHostType, - indexDuration_s, - fetchDuration_s, - cloneDuration_s, - }); } private async onIndexJobCompleted(job: Job) { diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index d664d6d7..9446b8a7 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2,7 +2,7 @@ import Ajv from "ajv"; import { auth } from "./auth"; -import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription } from "@/lib/serviceError"; +import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists } from "@/lib/serviceError"; import { prisma } from "@/prisma"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; @@ -14,7 +14,7 @@ import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { encrypt } from "@sourcebot/crypto" import { getConnection, getLinkedRepos } from "./data/connection"; -import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; +import { ConnectionSyncStatus, Prisma, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { headers } from "next/headers" import { getStripe } from "@/lib/stripe" import { getUser } from "@/data/user"; @@ -184,6 +184,19 @@ export const createSecret = async (key: string, value: string, domain: string): withOrgMembership(session, domain, async ({ orgId }) => { try { const encrypted = encrypt(value); + const existingSecret = await prisma.secret.findUnique({ + where: { + orgId_key: { + orgId, + key, + } + } + }); + + if (existingSecret) { + return secretAlreadyExists(); + } + await prisma.secret.create({ data: { orgId, diff --git a/packages/web/src/app/[domain]/components/configEditor.tsx b/packages/web/src/app/[domain]/components/configEditor.tsx index bc876ad6..effffede 100644 --- a/packages/web/src/app/[domain]/components/configEditor.tsx +++ b/packages/web/src/app/[domain]/components/configEditor.tsx @@ -19,7 +19,9 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Schema } from "ajv"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; - +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { PosthogEvent, PosthogEventMap } from "@/lib/posthogEvents"; +import { CodeHostType } from "@/lib/utils"; export type QuickActionFn = (previous: T) => T; export type QuickAction = { name: string; @@ -29,6 +31,7 @@ export type QuickAction = { interface ConfigEditorProps { value: string; + type: CodeHostType; // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange: (...event: any[]) => void; actions: QuickAction[], @@ -102,8 +105,8 @@ export const isConfigValidJson = (config: string) => { } const ConfigEditor = (props: ConfigEditorProps, forwardedRef: Ref) => { - const { value, onChange, actions, schema } = props; - + const { value, type, onChange, actions, schema } = props; + const captureEvent = useCaptureEvent(); const editorRef = useRef(null); useImperativeHandle( forwardedRef, @@ -159,6 +162,10 @@ const ConfigEditor = (props: ConfigEditorProps, forwardedRef: Ref { e.preventDefault(); + captureEvent('wa_config_editor_quick_action_pressed', { + name, + type, + }); if (editorRef.current?.view) { onQuickAction(fn, value, editorRef.current.view, { focusEditor: true, diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx index df425788..60ee5543 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx @@ -30,7 +30,7 @@ import githubPatCreation from "@/public/github_pat_creation.png" import { CodeHostType } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { isDefined } from '@/lib/utils' - +import useCaptureEvent from "@/hooks/useCaptureEvent"; interface SecretComboBoxProps { isDisabled: boolean; codeHostType: CodeHostType; @@ -47,6 +47,7 @@ export const SecretCombobox = ({ const [searchFilter, setSearchFilter] = useState(""); const domain = useDomain(); const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false); + const captureEvent = useCaptureEvent(); const { data: secrets, isLoading, refetch } = useQuery({ queryKey: ["secrets"], @@ -154,7 +155,12 @@ export const SecretCombobox = ({ {" "} - that has access to any private references. -

-
    - {notFoundData.users.length > 0 && ( -
  • - Users: - {notFoundData.users.join(', ')} -
  • - )} - {notFoundData.orgs.length > 0 && ( -
  • - {type === "gitlab" ? "Groups" : "Organizations"}: - {notFoundData.orgs.join(', ')} -
  • - )} - {notFoundData.repos.length > 0 && ( -
  • - {type === "gitlab" ? "Projects" : "Repositories"}: - {notFoundData.repos.join(', ')} -
  • - )} -
- - - - )} + +
- +
) diff --git a/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemErrorIndicator.tsx b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemErrorIndicator.tsx new file mode 100644 index 00000000..26c9ee21 --- /dev/null +++ b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemErrorIndicator.tsx @@ -0,0 +1,62 @@ +'use client' + +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { CircleX } from "lucide-react"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +interface ConnectionListItemErrorIndicatorProps { + failedRepos: { repoId: number; repoName: string; }[] | undefined; + connectionId: string; +} + +export const ConnectionListItemErrorIndicator = ({ + failedRepos, + connectionId +}: ConnectionListItemErrorIndicatorProps) => { + const captureEvent = useCaptureEvent() + + if (!failedRepos || failedRepos.length === 0) return null; + + return ( + + + { + captureEvent('wa_connection_list_item_error_pressed', {}) + window.location.href = `connections/${connectionId}` + }} + onMouseEnter={() => captureEvent('wa_connection_list_item_error_hover', {})} + /> + + +
+
+ +

Failed to Index Repositories

+
+
+

+ {failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load. +

+
+
+ {failedRepos.slice(0, 10).map(repo => ( + {repo.repoName} + ))} + {failedRepos.length > 10 && ( + + And {failedRepos.length - 10} more... + + )} +
+
+

+ Navigate to the connection for more details and to retry indexing. +

+
+
+
+
+ ); +}; diff --git a/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemManageButton.tsx b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemManageButton.tsx new file mode 100644 index 00000000..a0bb43c5 --- /dev/null +++ b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemManageButton.tsx @@ -0,0 +1,32 @@ +'use client' + +import { Button } from "@/components/ui/button"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { useRouter } from "next/navigation"; +import { useDomain } from "@/hooks/useDomain"; + +interface ConnectionListItemManageButtonProps { + id: string; +} + +export const ConnectionListItemManageButton = ({ + id +}: ConnectionListItemManageButtonProps) => { + const captureEvent = useCaptureEvent() + const router = useRouter(); + const domain = useDomain(); + + return ( + + ); +}; diff --git a/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemWarningIndicator.tsx b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemWarningIndicator.tsx new file mode 100644 index 00000000..690c9b18 --- /dev/null +++ b/packages/web/src/app/[domain]/connections/components/connectionList/connectionListItemWarningIndicator.tsx @@ -0,0 +1,78 @@ +'use client' + +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { AlertTriangle } from "lucide-react"; +import { NotFoundData } from "@/lib/syncStatusMetadataSchema"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + + +interface ConnectionListItemWarningIndicatorProps { + notFoundData: NotFoundData | null; + connectionId: string; + type: string; + displayWarning: boolean; +} + +export const ConnectionListItemWarningIndicator = ({ + notFoundData, + connectionId, + type, + displayWarning +}: ConnectionListItemWarningIndicatorProps) => { + const captureEvent = useCaptureEvent() + + if (!notFoundData || !displayWarning) return null; + + return ( + + + { + captureEvent('wa_connection_list_item_warning_pressed', {}) + window.location.href = `connections/${connectionId}` + }} + onMouseEnter={() => captureEvent('wa_connection_list_item_warning_hover', {})} + /> + + +
+
+ +

Unable to fetch all references

+
+

+ Some requested references couldn't be found. Verify the details below and ensure your connection is using a {" "} + {" "} + that has access to any private references. +

+
    + {notFoundData.users.length > 0 && ( +
  • + Users: + {notFoundData.users.join(', ')} +
  • + )} + {notFoundData.orgs.length > 0 && ( +
  • + {type === "gitlab" ? "Groups" : "Organizations"}: + {notFoundData.orgs.join(', ')} +
  • + )} + {notFoundData.repos.length > 0 && ( +
  • + {type === "gitlab" ? "Projects" : "Repositories"}: + {notFoundData.repos.join(', ')} +
  • + )} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/checkout.tsx b/packages/web/src/app/[domain]/onboard/components/checkout.tsx index ebe31eaf..1d46ba4b 100644 --- a/packages/web/src/app/[domain]/onboard/components/checkout.tsx +++ b/packages/web/src/app/[domain]/onboard/components/checkout.tsx @@ -12,6 +12,7 @@ import { Check, Loader2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { TEAM_FEATURES } from "@/lib/constants"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; export const Checkout = () => { const domain = useDomain(); @@ -20,6 +21,7 @@ export const Checkout = () => { const errorMessage = useNonEmptyQueryParam('errorMessage'); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); + const captureEvent = useCaptureEvent(); useEffect(() => { if (errorCode === ErrorCode.STRIPE_CHECKOUT_ERROR && errorMessage) { @@ -27,8 +29,11 @@ export const Checkout = () => { description: `⚠️ Stripe checkout failed with error: ${errorMessage}`, variant: "destructive", }); + captureEvent('wa_onboard_checkout_fail', { + error: errorMessage, + }); } - }, [errorCode, errorMessage, toast]); + }, [errorCode, errorMessage, toast, captureEvent]); const onCheckout = useCallback(() => { setIsLoading(true); @@ -39,14 +44,18 @@ export const Checkout = () => { description: `❌ Stripe checkout failed with error: ${response.message}`, variant: "destructive", }) + captureEvent('wa_onboard_checkout_fail', { + error: response.errorCode, + }); } else { router.push(response.url); + captureEvent('wa_onboard_checkout_success', {}); } }) .finally(() => { setIsLoading(false); }); - }, [domain, router, toast]); + }, [domain, router, toast, captureEvent]); return (
diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx index b43494d5..5c8ff025 100644 --- a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx +++ b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx @@ -14,7 +14,7 @@ import { useRouter } from "next/navigation"; import { useCallback } from "react"; import { OnboardingSteps } from "@/lib/constants"; import { Button } from "@/components/ui/button"; - +import useCaptureEvent from "@/hooks/useCaptureEvent"; interface ConnectCodeHostProps { nextStep: OnboardingSteps; } @@ -22,6 +22,7 @@ interface ConnectCodeHostProps { export const ConnectCodeHost = ({ nextStep }: ConnectCodeHostProps) => { const [selectedCodeHost, setSelectedCodeHost] = useState(null); const router = useRouter(); + const captureEvent = useCaptureEvent(); const onCreated = useCallback(() => { router.push(`?step=${nextStep}`); }, [nextStep, router]); @@ -101,11 +102,17 @@ const CodeHostButton = ({ logo, onClick, }: CodeHostButtonProps) => { + const captureEvent = useCaptureEvent(); return (
- Cancel + captureEvent('wa_invite_member_card_invite_cancel', { + num_emails: form.getValues().emails.length, + })}>Cancel onSubmit(form.getValues())} > diff --git a/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx b/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx index b29d9e77..3302ee13 100644 --- a/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx @@ -16,7 +16,7 @@ import { useCallback, useMemo, useState } from "react"; import { cancelInvite } from "@/actions"; import { useRouter } from "next/navigation"; import { useDomain } from "@/hooks/useDomain"; - +import useCaptureEvent from "@/hooks/useCaptureEvent"; interface Invite { id: string; email: string; @@ -36,6 +36,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { const { toast } = useToast(); const router = useRouter(); const domain = useDomain(); + const captureEvent = useCaptureEvent(); const filteredInvites = useMemo(() => { return invites @@ -59,14 +60,18 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { toast({ description: `❌ Failed to cancel invite. Reason: ${response.message}` }) + captureEvent('wa_invites_list_cancel_invite_fail', { + error: response.errorCode, + }) } else { toast({ description: `✅ Invite cancelled successfully.` }) + captureEvent('wa_invites_list_cancel_invite_success', {}) router.refresh(); } }); - }, [domain, toast, router]); + }, [domain, toast, router, captureEvent]); return (
@@ -126,11 +131,13 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { toast({ description: `✅ Copied invite link for ${invite.email} to clipboard` }) + captureEvent('wa_invites_list_copy_invite_link_success', {}) }) .catch(() => { toast({ description: "❌ Failed to copy invite link" }) + captureEvent('wa_invites_list_copy_invite_link_fail', {}) }) }} > @@ -152,11 +159,13 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { toast({ description: `✅ Email copied to clipboard.` }) + captureEvent('wa_invites_list_copy_email_success', {}) }) .catch(() => { toast({ description: `❌ Failed to copy email.` }) + captureEvent('wa_invites_list_copy_email_fail', {}) }) }} > diff --git a/packages/web/src/app/[domain]/settings/members/components/membersList.tsx b/packages/web/src/app/[domain]/settings/members/components/membersList.tsx index 8f5619d9..28f3e014 100644 --- a/packages/web/src/app/[domain]/settings/members/components/membersList.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/membersList.tsx @@ -15,6 +15,7 @@ import { transferOwnership, removeMemberFromOrg, leaveOrg } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; type Member = { id: string @@ -44,6 +45,7 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName } const [isTransferOwnershipDialogOpen, setIsTransferOwnershipDialogOpen] = useState(false) const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false) const router = useRouter(); + const captureEvent = useCaptureEvent(); const filteredMembers = useMemo(() => { return members @@ -68,10 +70,14 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName } toast({ description: `❌ Failed to remove member. Reason: ${response.message}` }) + captureEvent('wa_members_list_remove_member_fail', { + error: response.errorCode, + }) } else { toast({ description: `✅ Member removed successfully.` }) + captureEvent('wa_members_list_remove_member_success', {}) router.refresh(); } }); @@ -84,14 +90,18 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName } toast({ description: `❌ Failed to transfer ownership. Reason: ${response.message}` }) + captureEvent('wa_members_list_transfer_ownership_fail', { + error: response.errorCode, + }) } else { toast({ description: `✅ Ownership transferred successfully.` }) + captureEvent('wa_members_list_transfer_ownership_success', {}) router.refresh(); } }); - }, [domain, toast, router]); + }, [domain, toast, router, captureEvent]); const onLeaveOrg = useCallback(() => { leaveOrg(domain) @@ -100,10 +110,14 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName } toast({ description: `❌ Failed to leave organization. Reason: ${response.message}` }) + captureEvent('wa_members_list_leave_org_fail', { + error: response.errorCode, + }) } else { toast({ description: `✅ You have left the organization.` }) + captureEvent('wa_members_list_leave_org_success', {}) router.push("/"); } }); diff --git a/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx b/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx index 0a08024a..f18dd7c2 100644 --- a/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx +++ b/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx @@ -3,9 +3,16 @@ import { ENTERPRISE_FEATURES } from "@/lib/constants"; import { UpgradeCard } from "./upgradeCard"; import Link from "next/link"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; export const EnterpriseUpgradeCard = () => { + const captureEvent = useCaptureEvent(); + + const onClick = () => { + captureEvent('wa_enterprise_upgrade_card_pressed', {}); + } + return ( { priceDescription="tailored to your needs" features={ENTERPRISE_FEATURES} buttonText="Contact Us" + onClick={onClick} /> ) diff --git a/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx b/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx index 6a666324..159b5efe 100644 --- a/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx +++ b/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx @@ -8,6 +8,7 @@ import { isServiceError } from "@/lib/utils"; import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { TEAM_FEATURES } from "@/lib/constants"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; interface TeamUpgradeCardProps { buttonText: string; @@ -18,8 +19,10 @@ export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => { const { toast } = useToast(); const router = useRouter(); const [isLoading, setIsLoading] = useState(false); + const captureEvent = useCaptureEvent(); const onClick = useCallback(() => { + captureEvent('wa_team_upgrade_card_pressed', {}); setIsLoading(true); createStripeCheckoutSession(domain) .then((response) => { @@ -28,14 +31,18 @@ export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => { description: `❌ Stripe checkout failed with error: ${response.message}`, variant: "destructive", }); + captureEvent('wa_team_upgrade_checkout_fail', { + error: response.errorCode, + }); } else { router.push(response.url); + captureEvent('wa_team_upgrade_checkout_success', {}); } }) .finally(() => { setIsLoading(false); }); - }, [domain, router, toast]); + }, [domain, router, toast, captureEvent]); return ( - + - + diff --git a/packages/web/src/app/onboard/components/orgCreateForm.tsx b/packages/web/src/app/onboard/components/orgCreateForm.tsx index e05c9486..5c6892d5 100644 --- a/packages/web/src/app/onboard/components/orgCreateForm.tsx +++ b/packages/web/src/app/onboard/components/orgCreateForm.tsx @@ -13,26 +13,35 @@ import { Loader2 } from "lucide-react" import { useToast } from "@/components/hooks/use-toast" import { useRouter } from "next/navigation"; import { Card } from "@/components/ui/card" +import useCaptureEvent from "@/hooks/useCaptureEvent"; -const onboardingFormSchema = z.object({ - name: z.string() - .min(2, { message: "Organization name must be at least 3 characters long." }) - .max(30, { message: "Organization name must be at most 30 characters long." }), - domain: z.string() - .min(2, { message: "Organization domain must be at least 3 characters long." }) - .max(20, { message: "Organization domain must be at most 20 characters long." }) - .regex(/^[a-z][a-z-]*[a-z]$/, { - message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.", - }) - .refine(async (domain) => { - const doesDomainExist = await checkIfOrgDomainExists(domain); - return isServiceError(doesDomainExist) || !doesDomainExist; - }, "This domain is already taken."), -}) export function OrgCreateForm() { const { toast } = useToast(); const router = useRouter(); + const captureEvent = useCaptureEvent(); + + const onboardingFormSchema = z.object({ + name: z.string() + .min(2, { message: "Organization name must be at least 3 characters long." }) + .max(30, { message: "Organization name must be at most 30 characters long." }), + domain: z.string() + .min(2, { message: "Organization domain must be at least 3 characters long." }) + .max(20, { message: "Organization domain must be at most 20 characters long." }) + .regex(/^[a-z][a-z-]*[a-z]$/, { + message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.", + }) + .refine(async (domain) => { + const doesDomainExist = await checkIfOrgDomainExists(domain); + if (!isServiceError(doesDomainExist)) { + captureEvent('wa_onboard_org_create_fail', { + error: "Domain already exists", + }) + } + return isServiceError(doesDomainExist) || !doesDomainExist; + }, "This domain is already taken."), + }) + const form = useForm>({ resolver: zodResolver(onboardingFormSchema), defaultValues: { @@ -48,8 +57,12 @@ export function OrgCreateForm() { toast({ description: `❌ Failed to create organization. Reason: ${response.message}` }) + captureEvent('wa_onboard_org_create_fail', { + error: response.errorCode, + }) } else { router.push(`/${data.domain}/onboard`); + captureEvent('wa_onboard_org_create_success', {}) } }, [router, toast]); diff --git a/packages/web/src/app/posthogProvider.tsx b/packages/web/src/app/posthogProvider.tsx index e556678f..995ab850 100644 --- a/packages/web/src/app/posthogProvider.tsx +++ b/packages/web/src/app/posthogProvider.tsx @@ -1,43 +1,71 @@ 'use client' import { NEXT_PUBLIC_POSTHOG_PAPIK, NEXT_PUBLIC_POSTHOG_UI_HOST, NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED } from '@/lib/environment.client' import posthog from 'posthog-js' -import { PostHogProvider } from 'posthog-js/react' +import { usePostHog } from 'posthog-js/react' +import { PostHogProvider as PHProvider } from 'posthog-js/react' import { resolveServerPath } from './api/(client)/client' import { isDefined } from '@/lib/utils' +import { usePathname, useSearchParams } from "next/navigation" +import { useEffect, Suspense } from "react" -if (typeof window !== 'undefined') { - if (!NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED && isDefined(NEXT_PUBLIC_POSTHOG_PAPIK)) { - // @see next.config.mjs for path rewrites to the "/ingest" route. - const posthogHostPath = resolveServerPath('/ingest'); +const POSTHOG_ENABLED = isDefined(NEXT_PUBLIC_POSTHOG_PAPIK) && !NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED; - posthog.init(NEXT_PUBLIC_POSTHOG_PAPIK, { - api_host: posthogHostPath, - ui_host: NEXT_PUBLIC_POSTHOG_UI_HOST, - person_profiles: 'identified_only', - capture_pageview: false, // Disable automatic pageview capture - autocapture: false, // Disable automatic event capture - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sanitize_properties: (properties: Record, _event: string) => { - // https://posthog.com/docs/libraries/js#config - if (properties['$current_url']) { - properties['$current_url'] = null; - } - if (properties['$ip']) { - properties['$ip'] = null; - } - - return properties; +function PostHogPageView() { + const pathname = usePathname() + const searchParams = useSearchParams() + const posthog = usePostHog() + + // Track pageviews + useEffect(() => { + if (pathname && posthog) { + let url = window.origin + pathname + if (searchParams.toString()) { + url = url + `?${searchParams.toString()}` } - }); - } else { - console.log("PostHog telemetry disabled"); - } + + posthog.capture('$pageview', { '$current_url': url }) + } + }, [pathname, searchParams, posthog]) + + return null } -export function PHProvider({ - children, -}: { - children: React.ReactNode -}) { - return {children} +export function PostHogProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + if (POSTHOG_ENABLED) { + // @see next.config.mjs for path rewrites to the "/ingest" route. + const posthogHostPath = resolveServerPath('/ingest'); + + posthog.init(NEXT_PUBLIC_POSTHOG_PAPIK!, { + api_host: posthogHostPath, + ui_host: NEXT_PUBLIC_POSTHOG_UI_HOST, + capture_pageview: false, // Disable automatic pageview capture + autocapture: false, // Disable automatic event capture + // eslint-disable-next-line @typescript-eslint/no-explicit-any + /* @nocheckin HANDLE SELF HOSTED CASE + person_profiles: 'identified_only', + sanitize_properties: (properties: Record, _event: string) => { + // https://posthog.com/docs/libraries/js#config + if (properties['$current_url']) { + properties['$current_url'] = null; + } + if (properties['$ip']) { + properties['$ip'] = null; + } + + return properties; + } + */ + }); + } else { + console.log("PostHog telemetry disabled"); + } + }, []) + + return ( + + + {children} + + ) } \ No newline at end of file diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index d1666ce4..38144a4d 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -17,4 +17,5 @@ export enum ErrorCode { OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG', INVALID_INVITE = 'INVALID_INVITE', STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR', + SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS', } diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 114cf106..51a0ff5b 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -25,6 +25,200 @@ export type PosthogEventMap = { fileLanguages: string[] }, share_link_created: {}, -} + //////////////////////////////////////////////////////////////// + wa_secret_created_success: { + key: string, + }, + wa_secret_deleted_success: { + key: string, + }, + wa_secret_deleted_fail: { + key: string, + error: string, + }, + wa_secret_created_fail: { + key: string, + error: string, + }, + wa_secret_fetch_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_warning_nav_connection_fetch_fail: { + error: string, + }, + wa_warning_nav_hover: {}, + wa_warning_nav_pressed: {}, + wa_warning_nav_connection_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_error_nav_connection_fetch_fail: { + error: string, + }, + wa_error_nav_hover: {}, + wa_error_nav_pressed: {}, + wa_error_nav_job_pressed: {}, + wa_error_nav_job_fetch_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_progress_nav_connection_fetch_fail: { + error: string, + }, + wa_progress_nav_job_fetch_fail: { + error: string, + }, + wa_progress_nav_hover: {}, + wa_progress_nav_pressed: {}, + wa_progress_nav_job_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_trial_nav_pressed: {}, + wa_trial_nav_subscription_fetch_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_connection_list_item_error_hover: {}, + wa_connection_list_item_error_pressed: {}, + wa_connection_list_item_warning_hover: {}, + wa_connection_list_item_warning_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_connection_list_item_manage_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_create_connection_success: { + type: string, + }, + wa_create_connection_fail: { + type: string, + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_config_editor_quick_action_pressed: { + name: string, + type: string, + }, + ////////////////////////////////////////////////////////////////// + wa_secret_combobox_import_secret_pressed: { + type: string, + }, + wa_secret_combobox_import_secret_success: { + type: string, + }, + wa_secret_combobox_import_secret_fail: { + type: string, + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_billing_email_updated_success: {}, + wa_billing_email_updated_fail: { + error: string, + }, + wa_billing_email_fetch_fail: { + error: string, + }, + wa_manage_subscription_button_create_portal_session_success: {}, + wa_manage_subscription_button_create_portal_session_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_invite_member_card_invite_success: { + num_emails: number, + }, + wa_invite_member_card_invite_fail: { + error: string, + num_emails: number, + }, + wa_invite_member_card_invite_cancel: { + num_emails: number, + }, + ////////////////////////////////////////////////////////////////// + wa_onboard_skip_onboarding: { + step: string, + }, + wa_onboard_invite_team_invite_success: { + num_emails: number, + }, + wa_onboard_invite_team_invite_fail: { + error: string, + num_emails: number, + }, + wa_onboard_invite_team_skip: { + num_emails: number, + }, + ////////////////////////////////////////////////////////////////// + wa_members_list_remove_member_success: {}, + wa_members_list_remove_member_fail: { + error: string, + }, + wa_members_list_transfer_ownership_success: {}, + wa_members_list_transfer_ownership_fail: { + error: string, + }, + wa_members_list_leave_org_success: {}, + wa_members_list_leave_org_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_invites_list_cancel_invite_success: {}, + wa_invites_list_cancel_invite_fail: { + error: string, + }, + wa_invites_list_copy_invite_link_success: {}, + wa_invites_list_copy_invite_link_fail: {}, + wa_invites_list_copy_email_success: {}, + wa_invites_list_copy_email_fail: {}, + ////////////////////////////////////////////////////////////////// + wa_onboard_org_create_success: {}, + wa_onboard_org_create_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_connect_code_host_button_pressed: { + name: string, + }, + ////////////////////////////////////////////////////////////////// + wa_onboard_checkout_success: {}, + wa_onboard_checkout_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_team_upgrade_card_pressed: {}, + wa_team_upgrade_checkout_success: {}, + wa_team_upgrade_checkout_fail: { + error: string, + }, + wa_enterprise_upgrade_card_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_connection_delete_success: {}, + wa_connection_delete_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_connection_failed_status_hover: {}, + wa_connection_retry_sync_success: {}, + wa_connection_retry_sync_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// + wa_connection_not_found_warning_displayed: {}, + wa_connection_secrets_navigation_pressed: {}, + ////////////////////////////////////////////////////////////////// + wa_connection_retry_all_failed_repos_pressed: {}, + wa_connection_retry_all_failed_repos_fetch_fail: { + error: string, + }, + wa_connection_retry_all_failed_repos_fail: { + successCount: number, + failureCount: number, + }, + wa_connection_retry_all_failed_repos_success: { + successCount: number, + }, + wa_connection_retry_all_failed_no_repos: {}, + ////////////////////////////////////////////////////////////////// + wa_repo_retry_index_success: {}, + wa_repo_retry_index_fail: { + error: string, + }, + ////////////////////////////////////////////////////////////////// +} export type PosthogEvent = keyof PosthogEventMap; \ No newline at end of file diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index cbc2f318..325d8da3 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -99,4 +99,12 @@ export const orgInvalidSubscription = (): ServiceError => { errorCode: ErrorCode.ORG_INVALID_SUBSCRIPTION, message: "Invalid subscription", } +} + +export const secretAlreadyExists = (): ServiceError => { + return { + statusCode: StatusCodes.CONFLICT, + errorCode: ErrorCode.SECRET_ALREADY_EXISTS, + message: "Secret already exists", + } } \ No newline at end of file