From 6dcf27dbc06586ec4b6144d8c156ce286228aa46 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:55:29 +0400 Subject: [PATCH] feat(frontend): move PostHog trackers to the frontend (#11748) --- frontend/__tests__/posthog-tracking.test.tsx | 233 ++++++++++++++++++ .../conversation-websocket-context.tsx | 10 + frontend/src/hooks/use-tracking.ts | 35 +++ frontend/src/routes/accept-tos.tsx | 5 + frontend/src/routes/billing.tsx | 16 +- frontend/src/utils/error-handler.ts | 8 + 6 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 frontend/__tests__/posthog-tracking.test.tsx diff --git a/frontend/__tests__/posthog-tracking.test.tsx b/frontend/__tests__/posthog-tracking.test.tsx new file mode 100644 index 0000000000..5d76649013 --- /dev/null +++ b/frontend/__tests__/posthog-tracking.test.tsx @@ -0,0 +1,233 @@ +import { + describe, + it, + expect, + beforeAll, + afterAll, + afterEach, + vi, +} from "vitest"; +import { screen, waitFor, render, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createMockAgentErrorEvent } from "#/mocks/mock-ws-helpers"; +import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context"; +import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup"; +import { ConnectionStatusComponent } from "./helpers/websocket-test-components"; + +// Mock the tracking function +const mockTrackCreditLimitReached = vi.fn(); + +// Mock useTracking hook +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackCreditLimitReached: mockTrackCreditLimitReached, + trackLoginButtonClick: vi.fn(), + trackConversationCreated: vi.fn(), + trackPushButtonClick: vi.fn(), + trackPullButtonClick: vi.fn(), + trackCreatePrButtonClick: vi.fn(), + trackGitProviderConnected: vi.fn(), + trackUserSignupCompleted: vi.fn(), + trackCreditsPurchased: vi.fn(), + }), +})); + +// Mock useActiveConversation hook +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: null, + isLoading: false, + error: null, + }), +})); + +// MSW WebSocket mock setup +const { wsLink, server: mswServer } = conversationWebSocketTestSetup(); + +beforeAll(() => { + // The global MSW server from vitest.setup.ts is already running + // We just need to start our WebSocket-specific server + mswServer.listen({ onUnhandledRequest: "bypass" }); +}); + +afterEach(() => { + // Clear all mocks before each test + mockTrackCreditLimitReached.mockClear(); + mswServer.resetHandlers(); + // Clean up any React components + cleanup(); +}); + +afterAll(async () => { + // Close the WebSocket MSW server + mswServer.close(); + + // Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); +}); + +// Helper function to render components with all necessary providers +function renderWithProviders( + children: React.ReactNode, + conversationId = "test-conversation-123", + conversationUrl = "http://localhost:3000/api/conversations/test-conversation-123", +) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + {children} + + , + ); +} + +describe("PostHog Analytics Tracking", () => { + describe("Credit Limit Tracking", () => { + it("should track credit_limit_reached when AgentErrorEvent contains budget error", async () => { + // Create a mock AgentErrorEvent with budget-related error message + const mockBudgetErrorEvent = createMockAgentErrorEvent({ + error: "ExceededBudget: Task exceeded maximum budget of $10.00", + }); + + // Set up MSW to send the budget error event when connection is established + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + // Send the mock budget error event after connection + client.send(JSON.stringify(mockBudgetErrorEvent)); + }), + ); + + // Render with all providers + renderWithProviders(); + + // Wait for connection to be established + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + // Wait for the tracking event to be captured + await waitFor(() => { + expect(mockTrackCreditLimitReached).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: "test-conversation-123", + }), + ); + }); + }); + + it("should track credit_limit_reached when AgentErrorEvent contains 'credit' keyword", async () => { + // Create error with "credit" keyword (case-insensitive) + const mockCreditErrorEvent = createMockAgentErrorEvent({ + error: "Insufficient CREDIT to complete this operation", + }); + + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + client.send(JSON.stringify(mockCreditErrorEvent)); + }), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + await waitFor(() => { + expect(mockTrackCreditLimitReached).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: "test-conversation-123", + }), + ); + }); + }); + + it("should NOT track credit_limit_reached for non-budget errors", async () => { + // Create a regular error without budget/credit keywords + const mockRegularErrorEvent = createMockAgentErrorEvent({ + error: "Failed to execute command: Permission denied", + }); + + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + client.send(JSON.stringify(mockRegularErrorEvent)); + }), + ); + + renderWithProviders(); + + // Wait for connection and error to be processed + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + // Verify that credit_limit_reached was NOT tracked + expect(mockTrackCreditLimitReached).not.toHaveBeenCalled(); + }); + + it("should only track credit_limit_reached once per error event", async () => { + const mockBudgetErrorEvent = createMockAgentErrorEvent({ + error: "Budget exceeded: $10.00 limit reached", + }); + + mswServer.use( + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + // Send the same error event twice + client.send(JSON.stringify(mockBudgetErrorEvent)); + client.send( + JSON.stringify({ ...mockBudgetErrorEvent, id: "different-id" }), + ); + }), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("connection-state")).toHaveTextContent( + "OPEN", + ); + }); + + await waitFor(() => { + expect(mockTrackCreditLimitReached).toHaveBeenCalledTimes(2); + }); + + // Both calls should be for credit_limit_reached (once per event) + expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + conversationId: "test-conversation-123", + }), + ); + expect(mockTrackCreditLimitReached).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + conversationId: "test-conversation-123", + }), + ); + }); + }); +}); diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index a9f29fb426..bb56e1497e 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -27,8 +27,10 @@ import { } from "#/types/v1/type-guards"; import { handleActionEventCacheInvalidation } from "#/utils/cache-utils"; import { buildWebSocketUrl } from "#/utils/websocket-url"; +import { isBudgetOrCreditError } from "#/utils/error-handler"; import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types"; import EventService from "#/api/event-service/event-service.api"; +import { useTracking } from "#/hooks/use-tracking"; // eslint-disable-next-line @typescript-eslint/naming-convention export type V1_WebSocketConnectionState = @@ -69,6 +71,7 @@ export function ConversationWebSocketProvider({ const { removeOptimisticUserMessage } = useOptimisticUserMessageStore(); const { setExecutionStatus } = useV1ConversationStateStore(); const { appendInput, appendOutput } = useCommandStore(); + const { trackCreditLimitReached } = useTracking(); // History loading state const [isLoadingHistory, setIsLoadingHistory] = useState(true); @@ -132,6 +135,13 @@ export function ConversationWebSocketProvider({ // Handle AgentErrorEvent specifically if (isAgentErrorEvent(event)) { setErrorMessage(event.error); + + // Track credit limit reached if the error is budget-related + if (isBudgetOrCreditError(event.error)) { + trackCreditLimitReached({ + conversationId: conversationId || "unknown", + }); + } } // Clear optimistic user message when a user message is confirmed diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index 4b7959c1dd..0dfc0f0705 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -67,6 +67,38 @@ export const useTracking = () => { }); }; + const trackUserSignupCompleted = () => { + posthog.capture("user_signup_completed", { + signup_timestamp: new Date().toISOString(), + ...commonProperties, + }); + }; + + const trackCreditsPurchased = ({ + amountUsd, + stripeSessionId, + }: { + amountUsd: number; + stripeSessionId: string; + }) => { + posthog.capture("credits_purchased", { + amount_usd: amountUsd, + stripe_session_id: stripeSessionId, + ...commonProperties, + }); + }; + + const trackCreditLimitReached = ({ + conversationId, + }: { + conversationId: string; + }) => { + posthog.capture("credit_limit_reached", { + conversation_id: conversationId, + ...commonProperties, + }); + }; + return { trackLoginButtonClick, trackConversationCreated, @@ -74,5 +106,8 @@ export const useTracking = () => { trackPullButtonClick, trackCreatePrButtonClick, trackGitProviderConnected, + trackUserSignupCompleted, + trackCreditsPurchased, + trackCreditLimitReached, }; }; diff --git a/frontend/src/routes/accept-tos.tsx b/frontend/src/routes/accept-tos.tsx index 773f7ba2ee..f723f2a5f6 100644 --- a/frontend/src/routes/accept-tos.tsx +++ b/frontend/src/routes/accept-tos.tsx @@ -10,6 +10,7 @@ import { BrandButton } from "#/components/features/settings/brand-button"; import { handleCaptureConsent } from "#/utils/handle-capture-consent"; import { openHands } from "#/api/open-hands-axios"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { useTracking } from "#/hooks/use-tracking"; export default function AcceptTOS() { const posthog = usePostHog(); @@ -17,6 +18,7 @@ export default function AcceptTOS() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [isTosAccepted, setIsTosAccepted] = React.useState(false); + const { trackUserSignupCompleted } = useTracking(); // Get the redirect URL from the query parameters const redirectUrl = searchParams.get("redirect_url") || "/"; @@ -33,6 +35,9 @@ export default function AcceptTOS() { }); }, onSuccess: (response) => { + // Track user signup completion + trackUserSignupCompleted(); + // Get the redirect URL from the response const finalRedirectUrl = response.data.redirect_url || redirectUrl; diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index fdd410f6c4..c004d93dee 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -7,21 +7,35 @@ import { displaySuccessToast, } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; +import { useTracking } from "#/hooks/use-tracking"; function BillingSettingsScreen() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); + const { trackCreditsPurchased } = useTracking(); const checkoutStatus = searchParams.get("checkout"); React.useEffect(() => { if (checkoutStatus === "success") { + // Get purchase details from URL params + const amount = searchParams.get("amount"); + const sessionId = searchParams.get("session_id"); + + // Track credits purchased if we have the necessary data + if (amount && sessionId) { + trackCreditsPurchased({ + amountUsd: parseFloat(amount), + stripeSessionId: sessionId, + }); + } + displaySuccessToast(t(I18nKey.PAYMENT$SUCCESS)); } else if (checkoutStatus === "cancel") { displayErrorToast(t(I18nKey.PAYMENT$CANCELLED)); } setSearchParams({}); - }, [checkoutStatus]); + }, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]); return ; } diff --git a/frontend/src/utils/error-handler.ts b/frontend/src/utils/error-handler.ts index 385881e0ce..d479853b6d 100644 --- a/frontend/src/utils/error-handler.ts +++ b/frontend/src/utils/error-handler.ts @@ -50,3 +50,11 @@ export function showChatError({ status_update: true, }); } + +/** + * Checks if an error message indicates a budget or credit limit issue + */ +export function isBudgetOrCreditError(errorMessage: string): boolean { + const lowerCaseError = errorMessage.toLowerCase(); + return lowerCaseError.includes("budget") || lowerCaseError.includes("credit"); +}