Skip to content
This repository was archived by the owner on Jul 27, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ VITE_FRONTEND_PORT="3001" # Port to run the frontend application
VITE_USE_TLS="false" # Use HTTPS/WSS for proxied backend connections
VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxied backend requests

# App-level PostHog project key — enables useTracking (identified, behaviour analytics).
# Events route to https://us.i.posthog.com under this project. Omit to disable the
# PostHogProvider entirely (useTracking calls are silently dropped).
# VITE_POSTHOG_CLIENT_KEY="phc_..."

# Deployment environment — controls which PostHog project key is compiled in.
# Set to "production" or "staging" only in real deployment build pipelines.
# Leave unset locally so developers never accidentally send telemetry.
Expand Down
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,34 @@
- Verification command: `npm run typecheck && npm run build`.
- GitHub automation now includes `.github/workflows/ci.yml` for `npm ci`, `npm test`, and `npm run build`, plus `.github/dependabot.yml` with weekly npm/github-actions updates gated by a 7-day cooldown.

## Tracking / Analytics Architecture

Two distinct PostHog systems exist. **Never mix them at a call site.**

### System 1 — `telemetry.ts` (library-level, anonymous)
- **Purpose**: anonymous npm-consumer telemetry (`canvas_install`, `canvas_new_session`)
- **Keys**: hardcoded staging/prod keys in `telemetry.ts`; routed through `https://z.openhands.dev`
- **Consent**: `localStorage["openhands-telemetry-consent"]` via `useTelemetry` / `TelemetryConsentBanner`
- **`canvas_install`** fires once, pre-consent, per installation
- **Exports**: `trackEvent`, `useTelemetry`, `TelemetryConsentBanner`, etc. from `src/lib/index.ts` — these are the **public library API for npm consumers**
- **Rule**: Do NOT import `trackEvent` from `#/services/telemetry` in app routes or components

### System 2 — `useTracking` hook (app-level, identified)
- **Purpose**: product analytics for app behaviour events
- **Key**: `VITE_POSTHOG_CLIENT_KEY` env var → `OptionService.getConfig()` → `PostHogWrapper` → `PostHogProvider`; routed to `https://us.i.posthog.com`
- **Consent**: `user_consents_to_analytics` (backend setting) + `useSyncPostHogConsent` in `root-layout`; `AnalyticsConsentFormModal` also calls `setTelemetryConsent` to keep both systems in sync
- **All events** are typed, named functions in `src/hooks/use-tracking.ts` — add a new function there for every new event; never call `posthog.capture()` raw from a component
- **`commonProperties`** (`current_url`, `user_email`) are attached automatically by the hook
- **Rule**: Do NOT use raw `usePostHog()` + `posthog.capture()` in components — always go through `useTracking`

### Adding a new event
1. Add a typed function to `useTracking` in `src/hooks/use-tracking.ts`
2. Add the function to the hook's `return` object
3. Destructure and call it from the component: `const { trackFoo } = useTracking()`

### Env var
`VITE_POSTHOG_CLIENT_KEY` — see `.env.sample`. Without it, `PostHogProvider` never mounts and all `useTracking` calls are silently dropped (safe default for local dev).

## Runtime Services in Dev Stacks

- When the agent-canvas dev launchers (`npm run dev` / `dev:minimal` / the published `agent-canvas` binary) start a stack, they set a `VITE_RUNTIME_SERVICES_INFO` env var on the frontend describing which services are running and how the agent should reach them. The frontend forwards this verbatim as `AgentContext.system_message_suffix` on every `POST /api/conversations`, so conversations land with a `<RUNTIME_SERVICES>` block appended to the system prompt.
Expand Down
25 changes: 24 additions & 1 deletion __tests__/api/option-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { http, HttpResponse } from "msw";
import {
AgentServerUnavailableError,
Expand Down Expand Up @@ -98,4 +98,27 @@ describe("OptionService", () => {
expect(models.verified_providers).toEqual(["anthropic", "openhands"]);
expect(models.default_model).toBeTruthy();
});

describe("posthog_client_key", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("returns the key from VITE_POSTHOG_CLIENT_KEY when set", async () => {
vi.stubEnv("VITE_POSTHOG_CLIENT_KEY", "phc_test_key_123");

const config = await OptionService.getConfig();

expect(config.posthog_client_key).toBe("phc_test_key_123");
});

it("returns null when VITE_POSTHOG_CLIENT_KEY is not set", async () => {
// No stub — env var is absent in the test environment by default,
// so import.meta.env.VITE_POSTHOG_CLIENT_KEY is undefined,
// and the ?? null fallback applies.
const config = await OptionService.getConfig();

expect(config.posthog_client_key).toBeNull();
});
});
});
95 changes: 95 additions & 0 deletions __tests__/components/chat/chat-interface.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ vi.mock("#/hooks/use-agent-state", () => ({
})),
}));

const trackInitialQuerySubmittedMock = vi.fn();
const trackUserMessageSentMock = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackInitialQuerySubmitted: trackInitialQuerySubmittedMock,
trackUserMessageSent: trackUserMessageSentMock,
}),
}));

// Helper function to render with Router context
const renderChatInterfaceWithRouter = () =>
renderWithProviders(
Expand Down Expand Up @@ -823,3 +832,89 @@ describe("ChatInterface - Status Indicator", () => {
).not.toBeInTheDocument();
});
});

describe("ChatInterface - Tracking", () => {
let queryClient: QueryClient;

beforeEach(() => {
vi.clearAllMocks();
mockSend.mockResolvedValue({ queued: false });
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
useOptimisticUserMessageStore.setState({ pendingMessages: [] });
useErrorMessageStore.setState({ errorMessage: null });
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: {},
});
(
useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
useEventStore.setState({ events: [], eventIds: new Set(), uiEvents: [] });
});

function renderInterface() {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/test-conversation-id"]}>
<Routes>
<Route path=":conversationId" element={<ChatInterface />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}

it("calls trackInitialQuerySubmitted when the first message is sent (empty conversation)", async () => {
renderInterface();

act(() => {
useConversationStore.setState({ submittedMessage: "my first task" });
});

await waitFor(() => {
expect(trackInitialQuerySubmittedMock).toHaveBeenCalledWith(
expect.objectContaining({
queryCharacterLength: "my first task".length,
}),
);
});
});

it("calls trackUserMessageSent when a follow-up message is sent (non-empty conversation)", async () => {
// totalEvents = uiEvents.filter(shouldRenderAgentServerEvent).length.
// A MessageEvent (has llm_message.role + content) passes that filter,
// so seeding uiEvents with one makes totalEvents = 1.
useEventStore.setState({
events: [],
eventIds: new Set(),
uiEvents: [
{
id: "e1",
source: "user",
llm_message: { role: "user", content: "prior message" },
} as never,
],
});

renderInterface();

act(() => {
useConversationStore.setState({ submittedMessage: "follow-up" });
});

await waitFor(() => {
expect(trackUserMessageSentMock).toHaveBeenCalledWith(
expect.objectContaining({
currentMessageLength: "follow-up".length,
}),
);
});
expect(trackInitialQuerySubmittedMock).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

vi.mock("posthog-js/react", () => ({
usePostHog: () => ({ capture: vi.fn() }),
}));

const { saveUserSettingsMock } = vi.hoisted(() => ({
saveUserSettingsMock: vi.fn(),
}));

vi.mock("#/hooks/mutation/use-save-settings", () => ({
useSaveSettings: () => ({ mutate: saveUserSettingsMock }),
}));

const setTelemetryConsentMock = vi.fn((_consent: string) => Promise.resolve());
vi.mock("#/services/telemetry", () => ({
setTelemetryConsent: (consent: string) => setTelemetryConsentMock(consent),
}));

vi.mock("#/utils/handle-capture-consent", () => ({
handleCaptureConsent: vi.fn(),
}));

// Import after mocks.
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";

const onCloseMock = vi.fn();

describe("AnalyticsConsentFormModal", () => {
beforeEach(() => {
vi.clearAllMocks();
// Make saveUserSettings call onSuccess immediately.
saveUserSettingsMock.mockImplementation(
(_data: unknown, callbacks?: { onSuccess?: () => void }) => {
callbacks?.onSuccess?.();
},
);
});

it("calls setTelemetryConsent('granted') when user submits with analytics checked", () => {
render(<AnalyticsConsentFormModal onClose={onCloseMock} />);

// Checkbox is checked by default (defaultChecked).
fireEvent.submit(screen.getByTestId("user-capture-consent-form"));

expect(setTelemetryConsentMock).toHaveBeenCalledWith("granted");
});

it("calls setTelemetryConsent('denied') when user unchecks and submits", () => {
render(<AnalyticsConsentFormModal onClose={onCloseMock} />);

const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox); // uncheck
fireEvent.submit(screen.getByTestId("user-capture-consent-form"));

expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied");
});

it("calls onClose after successful submission", () => {
render(<AnalyticsConsentFormModal onClose={onCloseMock} />);

fireEvent.submit(screen.getByTestId("user-capture-consent-form"));

expect(onCloseMock).toHaveBeenCalled();
});

it("saves user_consents_to_analytics: true when checkbox is checked", () => {
render(<AnalyticsConsentFormModal onClose={onCloseMock} />);

fireEvent.submit(screen.getByTestId("user-capture-consent-form"));

expect(saveUserSettingsMock).toHaveBeenCalledWith(
{ user_consents_to_analytics: true },
expect.any(Object),
);
});

it("saves user_consents_to_analytics: false when checkbox is unchecked", () => {
render(<AnalyticsConsentFormModal onClose={onCloseMock} />);

fireEvent.click(screen.getByRole("checkbox"));
fireEvent.submit(screen.getByTestId("user-capture-consent-form"));

expect(saveUserSettingsMock).toHaveBeenCalledWith(
{ user_consents_to_analytics: false },
expect.any(Object),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ vi.mock("react-i18next", async () => {
};
});

vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackDownloadVsCodeButtonClicked: vi.fn(),
}),
}));

describe("ConversationCard", () => {
const onClick = vi.fn();
const onDelete = vi.fn();
Expand Down
33 changes: 33 additions & 0 deletions __tests__/components/shared/modals/settings/settings-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { SettingsForm } from "#/components/shared/modals/settings/settings-form"
import { DEFAULT_SETTINGS } from "#/services/settings";
import { getAgentSettingValue } from "#/utils/sdk-settings-schema";

const trackSettingsSavedMock = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSettingsSaved: trackSettingsSavedMock,
}),
}));

describe("SettingsForm", () => {
const onCloseMock = vi.fn();
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
Expand Down Expand Up @@ -110,4 +117,30 @@ describe("SettingsForm", () => {
expect(onCloseMock).toHaveBeenCalled();
});
});

it("calls trackSettingsSaved with LLM details when form is submitted", async () => {
renderWithProviders(
<SettingsForm settings={DEFAULT_SETTINGS} onClose={onCloseMock} />,
{
navigation: { currentPath: "/settings" },
},
);

await waitFor(() => {
expect(screen.getByTestId("llm-model-input")).toHaveValue(
expectedModelName,
);
});

fireEvent.submit(screen.getByTestId("settings-form"));

await waitFor(() => {
expect(trackSettingsSavedMock).toHaveBeenCalledWith(
expect.objectContaining({
llmApiKeySet: expect.stringMatching(/^(SET|UNSET)$/),
searchApiKeySet: expect.stringMatching(/^(SET|UNSET)$/),
}),
);
});
});
});
Loading
Loading