mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -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";
|
} from "#/types/v1/type-guards";
|
||||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||||
|
import { isBudgetOrCreditError } from "#/utils/error-handler";
|
||||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||||
import EventService from "#/api/event-service/event-service.api";
|
import EventService from "#/api/event-service/event-service.api";
|
||||||
|
import { useTracking } from "#/hooks/use-tracking";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
export type V1_WebSocketConnectionState =
|
export type V1_WebSocketConnectionState =
|
||||||
@@ -69,6 +71,7 @@ export function ConversationWebSocketProvider({
|
|||||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||||
const { setExecutionStatus } = useV1ConversationStateStore();
|
const { setExecutionStatus } = useV1ConversationStateStore();
|
||||||
const { appendInput, appendOutput } = useCommandStore();
|
const { appendInput, appendOutput } = useCommandStore();
|
||||||
|
const { trackCreditLimitReached } = useTracking();
|
||||||
|
|
||||||
// History loading state
|
// History loading state
|
||||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||||
@@ -132,6 +135,13 @@ export function ConversationWebSocketProvider({
|
|||||||
// Handle AgentErrorEvent specifically
|
// Handle AgentErrorEvent specifically
|
||||||
if (isAgentErrorEvent(event)) {
|
if (isAgentErrorEvent(event)) {
|
||||||
setErrorMessage(event.error);
|
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
|
// 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 {
|
return {
|
||||||
trackLoginButtonClick,
|
trackLoginButtonClick,
|
||||||
trackConversationCreated,
|
trackConversationCreated,
|
||||||
@@ -74,5 +106,8 @@ export const useTracking = () => {
|
|||||||
trackPullButtonClick,
|
trackPullButtonClick,
|
||||||
trackCreatePrButtonClick,
|
trackCreatePrButtonClick,
|
||||||
trackGitProviderConnected,
|
trackGitProviderConnected,
|
||||||
|
trackUserSignupCompleted,
|
||||||
|
trackCreditsPurchased,
|
||||||
|
trackCreditLimitReached,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
|||||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||||
import { openHands } from "#/api/open-hands-axios";
|
import { openHands } from "#/api/open-hands-axios";
|
||||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||||
|
import { useTracking } from "#/hooks/use-tracking";
|
||||||
|
|
||||||
export default function AcceptTOS() {
|
export default function AcceptTOS() {
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
@@ -17,6 +18,7 @@ export default function AcceptTOS() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
|
||||||
|
const { trackUserSignupCompleted } = useTracking();
|
||||||
|
|
||||||
// Get the redirect URL from the query parameters
|
// Get the redirect URL from the query parameters
|
||||||
const redirectUrl = searchParams.get("redirect_url") || "/";
|
const redirectUrl = searchParams.get("redirect_url") || "/";
|
||||||
@@ -33,6 +35,9 @@ export default function AcceptTOS() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
|
// Track user signup completion
|
||||||
|
trackUserSignupCompleted();
|
||||||
|
|
||||||
// Get the redirect URL from the response
|
// Get the redirect URL from the response
|
||||||
const finalRedirectUrl = response.data.redirect_url || redirectUrl;
|
const finalRedirectUrl = response.data.redirect_url || redirectUrl;
|
||||||
|
|
||||||
|
|||||||
@@ -7,21 +7,35 @@ import {
|
|||||||
displaySuccessToast,
|
displaySuccessToast,
|
||||||
} from "#/utils/custom-toast-handlers";
|
} from "#/utils/custom-toast-handlers";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
|
import { useTracking } from "#/hooks/use-tracking";
|
||||||
|
|
||||||
function BillingSettingsScreen() {
|
function BillingSettingsScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { trackCreditsPurchased } = useTracking();
|
||||||
const checkoutStatus = searchParams.get("checkout");
|
const checkoutStatus = searchParams.get("checkout");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (checkoutStatus === "success") {
|
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));
|
displaySuccessToast(t(I18nKey.PAYMENT$SUCCESS));
|
||||||
} else if (checkoutStatus === "cancel") {
|
} else if (checkoutStatus === "cancel") {
|
||||||
displayErrorToast(t(I18nKey.PAYMENT$CANCELLED));
|
displayErrorToast(t(I18nKey.PAYMENT$CANCELLED));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
}, [checkoutStatus]);
|
}, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]);
|
||||||
|
|
||||||
return <PaymentForm />;
|
return <PaymentForm />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,3 +50,11 @@ export function showChatError({
|
|||||||
status_update: true,
|
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