mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
feat(frontend): move PostHog trackers to the frontend (#11748)
This commit is contained in:
233
frontend/__tests__/posthog-tracking.test.tsx
Normal file
233
frontend/__tests__/posthog-tracking.test.tsx
Normal file
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversationUrl}
|
||||
sessionApiKey={null}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(<ConnectionStatusComponent />);
|
||||
|
||||
// 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(<ConnectionStatusComponent />);
|
||||
|
||||
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(<ConnectionStatusComponent />);
|
||||
|
||||
// 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(<ConnectionStatusComponent />);
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 <PaymentForm />;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user