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");
+}