fix: preserve Canvas analytics identity (#1839)

* fix: unify Canvas PostHog identity

* fix: preserve funnel events across backend transitions

* fix: preserve telemetry consent through cloud login

* fix: isolate Canvas telemetry from host PostHog

* fix: preserve telemetry during client startup

* fix: centralize Canvas telemetry ownership

* fix: make telemetry lifecycle atomic

---------

Co-authored-by: neubig <neubig@users.noreply.github.com>
This commit is contained in:
Graham Neubig
2026-07-19 20:16:20 +01:00
committed by GitHub
co-authored by neubig
parent 1f404b3774
commit 3598bb14e3
66 changed files with 1583 additions and 1313 deletions
+4 -9
View File
@@ -18,15 +18,10 @@ VITE_USE_TLS="false" # Use HTTPS/WSS for proxied backend connections
VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxied backend requests
# VITE_BASE_PATH="/canvas" # Build/serve the SPA under a subpath. Leave unset for root-local development.
# 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.
# VITE_APP_ENV="production"
# Public PostHog client key compiled into source builds. Precompiled library
# consumers can instead pass apiKey/apiHost/uiHost to AgentServerUIProviders.
# VITE_POSTHOG_API_KEY="phc_..."
# VITE_DO_NOT_TRACK="1" # Disable all telemetry, including anonymous install counts
# Mocking / test helpers
VITE_MOCK_API="false" # Enable/disable API mocking with MSW
+3 -6
View File
@@ -175,11 +175,9 @@ jobs:
# everything else (PR / main / local) uses staging. Both keys are
# public, client-side keys — not secrets — so they live in repo vars.
if [[ "$RELEVANT_REF" == refs/tags/v* ]]; then
echo "vite_app_env=production" >> "$GITHUB_OUTPUT"
echo "posthog_client_key=$POSTHOG_PROD_KEY" >> "$GITHUB_OUTPUT"
echo "posthog_api_key=$POSTHOG_PROD_KEY" >> "$GITHUB_OUTPUT"
else
echo "vite_app_env=" >> "$GITHUB_OUTPUT"
echo "posthog_client_key=$POSTHOG_STAGING_KEY" >> "$GITHUB_OUTPUT"
echo "posthog_api_key=$POSTHOG_STAGING_KEY" >> "$GITHUB_OUTPUT"
fi
echo "=== Build outputs ==="
@@ -202,8 +200,7 @@ jobs:
AGENT_CANVAS_VERSION=${{ steps.config.outputs.agent_canvas_version }}
OPENHANDS_BUILD_GIT_SHA=${{ env.RELEVANT_SHA }}
OPENHANDS_BUILD_GIT_REF=${{ env.RELEVANT_REF }}
VITE_APP_ENV=${{ steps.prep.outputs.vite_app_env }}
VITE_POSTHOG_CLIENT_KEY=${{ steps.prep.outputs.posthog_client_key }}
VITE_POSTHOG_API_KEY=${{ steps.prep.outputs.posthog_api_key }}
VITE_BASE_PATH=${{ steps.config.outputs.canvas_base_path }}
cache-from: type=gha
cache-to: type=gha,mode=max
+6 -5
View File
@@ -61,14 +61,15 @@ jobs:
- name: Build app
env:
# Build the packaged static app the same way Docker tagged releases are
# built: production telemetry key and app-level PostHog key baked in.
# These are public, client-side project keys, not secrets.
VITE_APP_ENV: production
VITE_POSTHOG_CLIENT_KEY: ${{ vars.POSTHOG_PROD_KEY }}
# Public client key baked into the packaged production app.
VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }}
run: npm run build
- name: Build library
env:
# The precompiled library keeps the production default; embedded
# consumers can still override it through runtime analytics config.
VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }}
run: npm run build:lib
- name: Verify package contents
+15 -23
View File
@@ -30,37 +30,31 @@ by a human, report the exact validator error rather than editing them yourself.
## Tracking / Analytics Architecture
Two distinct PostHog systems exist. **Never mix them at a call site.**
One Canvas-owned PostHog client owns telemetry and app analytics.
### System 1 — `telemetry.ts` (Canvas-level, anonymous)
- **Purpose**: anonymous npm-consumer telemetry and the consented OSS Cloud funnel
- **Keys**: hardcoded staging/prod keys in `telemetry.ts`; routed through `https://z.openhands.dev`
- **Isolation**: the named `agent-canvas` PostHog instance has its own persistence and consent namespaces; never replace it with the default singleton
- **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**: app routes/components use the typed functions in `cloud-funnel-analytics.ts`; do not call `trackEvent` directly
### 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`
- `src/services/telemetry.ts` is the only module that accesses the named `agent-canvas` PostHog client. The name isolates Canvas identity, persistence, configuration, and consent from an embedding host's default singleton. React code declares identity through `setTelemetryIdentity()` and captures through the service; it never receives or resets the SDK client.
- `TelemetryProvider` configures bootstrap/runtime options and eagerly initializes the service. It does not expose PostHog context or maintain a second client lifecycle.
- Unconfigured source builds use the staging key and route through `https://z.openhands.dev`. Release workflows pass the public production key through `VITE_POSTHOG_API_KEY`. Precompiled npm consumers override `apiKey`, `apiHost`, and `uiHost` at runtime through `AgentServerUIProviders.analytics` or `configureTelemetry()`.
- `setTelemetryConsent` is the only user-consent controller; `configureTelemetry(false)` is the embedding host's hard disable. An explicit first-run browser decision remains pending across local backends until `useSyncTelemetryConsent` persists it to Cloud; a stale/default backend value must not overwrite that newer choice during login or navigation. Once Cloud confirms the choice, backend `user_consents_to_analytics` changes are authoritative and mirrored to the client. No other hook or component should call `opt_in_capturing` / `opt_out_capturing` directly.
- `subscribeTelemetryConsent` is the sole React-facing consent store. Hooks that render consent state must use `useSyncExternalStore`; do not mirror consent in component state or gate events outside `telemetry.ts`.
- `canvas_install` fires once, pre-consent, with the client's anonymous distinct ID. After consent, Cloud `identify()` follows PostHog's normal anonymous-to-identified lifecycle, so install, Cloud-funnel, and app events remain queryable as one user journey. Account changes and logout reset identity inside `telemetry.ts`, which immediately restores the canonical consent state that the PostHog SDK reset clears.
- `telemetry.ts` adds immutable `client_source`, `client_version`, `package_name`, and `package_version` properties in `before_send`, so reset cannot remove attribution and event producers cannot override it. Repeated business milestones use deterministic PostHog `$insert_id` values instead of process-local caches.
- `trackEvent`, `useTelemetry`, and `TelemetryConsentBanner` remain the public library telemetry API for npm consumers. Non-React state machines use typed functions in `cloud-funnel-analytics.ts`; they do not call `trackEvent` directly.
- React app events use typed functions in `src/hooks/use-tracking.ts`; components never call `posthog.capture()` raw. The hook attaches `current_url` and `user_email` automatically and captures through the telemetry service. It may read backend settings for event properties, but must never gate capture on a settings snapshot: `useSyncTelemetryConsent` has already mirrored the authoritative decision to the telemetry service, and settings can be stale during a backend transition.
- A business milestone has one canonical event capture. Do not conditionally switch between telemetry and app clients or emit duplicate events.
### Cloud funnel observability
- OAuth device authorization and Cloud conversation-start requests include the coarse `X-OpenHands-Client: agent_canvas` and `X-OpenHands-Client-Version` headers from `src/api/client-source.ts`. Never put device codes, API keys, conversation content, raw hosts, or other user data in these headers.
- Production ingress must retain those two headers as structured Datadog facets before source-specific operational queries will work.
- The consented OSS funnel uses typed `cloud_device_authorization_started`, `cloud_device_authorization_succeeded`, `backend_added`, and `cloud_conversation_ready` events from `cloud-funnel-analytics.ts`.
- The consented OSS funnel uses typed `cloud_device_authorization_started`, `cloud_device_authorization_succeeded`, and `cloud_conversation_ready` events from `cloud-funnel-analytics.ts`; React emits the canonical `backend_added` event 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).
### Env vars
`VITE_POSTHOG_API_KEY` is the sole build-time PostHog key. Unconfigured source builds use staging; official release workflows set production explicitly. Precompiled consumers use runtime configuration instead.
## Runtime Services in Dev Stacks
@@ -275,7 +269,6 @@ Ensure each test is meaningful, concise, and covers a unique aspect of user inte
- `npm test` now runs `npm run make-i18n` first so clean environments generate `src/i18n/declaration.ts` before Vitest loads aliased imports.
- `__tests__/vite-config.test.ts` should import `vite.config` directly under `// @vitest-environment node`; spawning plain `node -e 'import ./vite.config.ts'` is not portable across Node patch releases in CI.
- `vitest.setup.ts` must guard DOM-specific globals (`HTMLCanvasElement`, `HTMLElement`, `window`) because some suites run in the Node environment instead of jsdom.
- `__tests__/components/providers/posthog-wrapper.test.tsx` must wrap `PostHogWrapper` in a `QueryClientProvider`; the wrapper now reads its client from React Query context instead of importing the global singleton.
- WebSocket hook regression note: `__tests__/hooks/use-websocket.test.ts`'s `onClose` callback assertion was flaky against the shared MSW websocket server in CI; keep that single test on a deterministic stubbed `WebSocket` close path instead of relying on MSW close timing.
- Library i18n regression note: `__tests__/i18n/library-namespace.test.ts` imports `../../src/index`, which can take >5s under the full Vitest suite after `vi.resetModules()`. Keep an explicit per-test timeout (currently 15s) so the suite doesn't fail on slow workers.
@@ -499,7 +492,6 @@ When adding code that needs a new string, decide up front which rule it falls un
- Agent-server recovery UX gotchas:
- Keep `/settings/agent-server` in the intermediate-page bypass path (`use-is-on-intermediate-page`) so `useConfig()`-driven layout/sidebar queries do not block the recovery screen behind a global spinner.
- `PostHogWrapper` should treat config-fetch failures as silent/optional (no user-facing toast), otherwise onboarding/recovery screens show a duplicate incompatible-server toast on top of the friendly guidance.
- Keep the settings route on the compact `AgentServerConnectionForm` variant with `showSectionHeader={false}` and no checklist; the blocked root onboarding should stay similarly minimal, with only the status card plus a single sentence that links to the repo setup instructions.
- For local screenshot/GIF capture of SPA routes, serve `build/` with an SPA fallback (for example `sirv build --single`) and restart the static server after each rebuild so hashed asset URLs stay in sync.
@@ -650,7 +642,7 @@ When adding code that needs a new string, decide up front which rule it falls un
- CI workflow: a `Read defaults from config/defaults.json` step uses `node -p` to extract values into `$GITHUB_OUTPUT`.
- Dockerfile ARG defaults are kept as fallbacks for local `docker build` without the CI workflow; CI always passes `--build-arg` overrides from the JSON.
- To bump a version, edit `config/defaults.json` only — the JS scripts, Docker build, and CI workflow all derive their values from it.
- Docker all-in-one image: `.github/workflows/docker.yml` builds and publishes `ghcr.io/openhands/agent-canvas` — a combined image that bundles the agent-server (from `ghcr.io/openhands/agent-server`), the automation server (`openhands-automation` via pip), and the agent-canvas frontend (static build). The Dockerfile lives at `docker/Dockerfile`, the entrypoint at `docker/entrypoint.sh`. The workflow structure mirrors the SDK repo's `server.yml`: a `build-and-push-image` matrix job (2 × arch: amd64 on `ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`) pushes arch-suffixed tags, then `merge-manifests` creates multi-arch manifests via `docker buildx imagetools create`, then `consolidate-build-info` aggregates artifacts, and `update-pr-description` updates the PR body (using `<!-- AGENT_CANVAS_DOCKER_START -->` / `<!-- AGENT_CANVAS_DOCKER_END -->` markers). The workflow triggers on push to main, `v*` tags (releases), PRs, and `workflow_dispatch`. On release tags it also pushes semver tags (e.g. `1.2.3`, `1.2`, `1`, `latest`). Fork PRs are skipped (no GHCR auth). On PRs that link an `OpenHands/software-agent-sdk` PR in the description, the Docker workflow uses that SDK PR's published branch image (`ghcr.io/openhands/agent-server:<branch-with-slashes-as-dashes>-python`) as the agent-server base image unless a `workflow_dispatch` input explicitly overrides it. The image exposes port 8000 as a unified entry point: `/api/automation/*` → automation (:18001), `/api/*` → agent-server (:18000), `/*` → static frontend. The Dockerfile accepts a `VITE_APP_ENV` build arg (default empty → staging PostHog key); the CI workflow passes `VITE_APP_ENV=production` only for tagged releases (`refs/tags/v*`), so PR and main-branch images use the staging key while release images use the production key, matching the `build:lib` npm path. The entrypoint auto-generates **both** the session API key and `OH_SECRET_KEY` (persisted to `~/.openhands/agent-canvas/session-api-key.txt` and `secret-key.txt` respectively) when none is provided, so the image runs secure by default. Users can override either via env var (`OH_SECRET_KEY`, `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0`). `scripts/dev-safe.mjs` uses the same `secret-key.txt` file, so dev mode and Docker share the same key when both use the same `~/.openhands` directory.
- Docker all-in-one image: `.github/workflows/docker.yml` builds and publishes `ghcr.io/openhands/agent-canvas` — a combined image that bundles the agent-server (from `ghcr.io/openhands/agent-server`), the automation server (`openhands-automation` via pip), and the agent-canvas frontend (static build). The Dockerfile lives at `docker/Dockerfile`, the entrypoint at `docker/entrypoint.sh`. The workflow structure mirrors the SDK repo's `server.yml`: a `build-and-push-image` matrix job (2 × arch: amd64 on `ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`) pushes arch-suffixed tags, then `merge-manifests` creates multi-arch manifests via `docker buildx imagetools create`, then `consolidate-build-info` aggregates artifacts, and `update-pr-description` updates the PR body (using `<!-- AGENT_CANVAS_DOCKER_START -->` / `<!-- AGENT_CANVAS_DOCKER_END -->` markers). The workflow triggers on push to main, `v*` tags (releases), PRs, and `workflow_dispatch`. On release tags it also pushes semver tags (e.g. `1.2.3`, `1.2`, `1`, `latest`). Fork PRs are skipped (no GHCR auth). On PRs that link an `OpenHands/software-agent-sdk` PR in the description, the Docker workflow uses that SDK PR's published branch image (`ghcr.io/openhands/agent-server:<branch-with-slashes-as-dashes>-python`) as the agent-server base image unless a `workflow_dispatch` input explicitly overrides it. The image exposes port 8000 as a unified entry point: `/api/automation/*` → automation (:18001), `/api/*` → agent-server (:18000), `/*` → static frontend. The Dockerfile accepts the public `VITE_POSTHOG_API_KEY` build arg; CI passes staging for PR/main images and production for tagged releases. The npm release workflow passes the same production key to both the app and library builds. The entrypoint auto-generates **both** the session API key and `OH_SECRET_KEY` (persisted to `~/.openhands/agent-canvas/session-api-key.txt` and `secret-key.txt` respectively) when none is provided, so the image runs secure by default. Users can override either via env var (`OH_SECRET_KEY`, `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0`). `scripts/dev-safe.mjs` uses the same `secret-key.txt` file, so dev mode and Docker share the same key when both use the same `~/.openhands` directory.
- Spec files live under `specs/`. Spec IDs are stable — never renumber. Mark deprecated specs with ~~strikethrough~~. Tag implementation code and tests with `// @spec BM-002 — Short title` comments so specs are grep-able across the codebase (`grep -rn '@spec BM-' src/ __tests__/`). Place the comment on the line immediately above the relevant code block or test. When multiple tests cover the same spec, use `it.each` if the test structure is identical.
+32 -13
View File
@@ -9,12 +9,10 @@ vi.mock("react-i18next", async (importOriginal) =>
importOriginal<typeof import("react-i18next")>(),
);
import OptionService from "#/api/option-service/option-service.api";
import {
AGENT_SERVER_UI_SCOPE_SELECTOR,
AgentServerUIRoot,
AgentServerUIProviders,
DEFAULT_AGENT_SERVER_ANALYTICS,
OPENHANDS_I18N_NAMESPACE,
getDefaultI18n,
getDefaultQueryClient,
@@ -26,8 +24,15 @@ import {
} from "#/index";
import i18n from "#/i18n";
vi.mock("posthog-js/react", () => ({
PostHogProvider: ({ children }: { children: React.ReactNode }) => children,
const telemetryProviderMock = vi.hoisted(() => vi.fn());
vi.mock("#/components/providers/telemetry-provider", () => ({
TelemetryProvider: (props: {
children: React.ReactNode;
config?: unknown;
}) => {
telemetryProviderMock(props);
return props.children;
},
}));
const BaseProbe = ({ translation }: { translation?: string }) => {
@@ -165,10 +170,8 @@ describe("AgentServerUIProviders", () => {
expect(getI18n()).toBe(getDefaultI18n());
});
it("only mounts PostHog analytics when the host app opts in", async () => {
const getConfigSpy = vi
.spyOn(OptionService, "getConfig")
.mockResolvedValue({ posthog_client_key: "phc_test_key" } as never);
it("passes disabled and runtime analytics configuration to TelemetryProvider", () => {
telemetryProviderMock.mockClear();
const noAnalyticsView = render(
<AgentServerUIProviders>
@@ -177,19 +180,35 @@ describe("AgentServerUIProviders", () => {
);
expect(screen.getByTestId("child")).toHaveTextContent("child");
expect(getConfigSpy).not.toHaveBeenCalled();
expect(telemetryProviderMock).toHaveBeenCalledWith(
expect.objectContaining({ config: false }),
);
noAnalyticsView.unmount();
telemetryProviderMock.mockClear();
const analytics = {
provider: "posthog" as const,
apiKey: "phc_embedded",
apiHost: "https://events.example.com",
uiHost: "https://posthog.example.com",
};
render(
<AgentServerUIProviders analytics={DEFAULT_AGENT_SERVER_ANALYTICS}>
<AgentServerUIProviders analytics={analytics}>
<div data-testid="child-with-analytics">child</div>
</AgentServerUIProviders>,
);
await waitFor(() => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
});
expect(telemetryProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
config: {
apiKey: analytics.apiKey,
apiHost: analytics.apiHost,
uiHost: analytics.uiHost,
},
}),
);
});
it("wraps children in a scoped, customizable style root by default", () => {
+4 -27
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { http, HttpResponse } from "msw";
import {
AgentServerUnavailableError,
@@ -72,7 +72,9 @@ describe("OptionService", () => {
await expect(OptionService.getConfig()).rejects.toMatchObject({
name: AgentServerUnavailableError.name,
message: expect.stringContaining("Could not connect to the configured agent server"),
message: expect.stringContaining(
"Could not connect to the configured agent server",
),
details: expect.stringContaining("Request failed"),
});
});
@@ -126,29 +128,4 @@ describe("OptionService", () => {
]);
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 () => {
// Force the env var absent so the ?? null fallback applies. A developer's
// local .env may define VITE_POSTHOG_CLIENT_KEY, which vitest loads and
// would otherwise leak into this test; stubbing to undefined deletes it.
vi.stubEnv("VITE_POSTHOG_CLIENT_KEY", undefined);
const config = await OptionService.getConfig();
expect(config.posthog_client_key).toBeNull();
});
});
});
@@ -9,10 +9,6 @@ import {
import { AddAutomationModal } from "#/components/features/automations/add-automation-modal";
import { I18nKey } from "#/i18n/declaration";
vi.mock("posthog-js/react", () => ({
usePostHog: () => undefined,
}));
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => ({ data: { user_consents_to_analytics: true } }),
}));
@@ -1,7 +1,7 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
NavigationProvider,
type NavigationContextValue,
@@ -9,11 +9,7 @@ import {
import { CreateInstructions } from "#/components/features/automations/create-instructions";
import { I18nKey } from "#/i18n/declaration";
import { useConversationStore } from "#/stores/conversation-store";
const captureMock = vi.fn();
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({ capture: captureMock }),
}));
import * as telemetry from "#/services/telemetry";
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => ({ data: { user_consents_to_analytics: true } }),
@@ -86,11 +82,19 @@ function renderCreateInstructions() {
}
describe("CreateInstructions", () => {
let captureMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
captureMock.mockClear();
captureMock = vi
.spyOn(telemetry, "trackEvent")
.mockResolvedValue(undefined);
useConversationStore.setState({ messageToSend: null });
});
afterEach(() => {
captureMock.mockRestore();
});
it("captures automation_created with the active backend kind when Create Automation is clicked", async () => {
const user = userEvent.setup();
renderCreateInstructions();
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MockInstance } from "vitest";
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import {
@@ -10,6 +11,7 @@ import {
type NavigationContextValue,
} from "#/context/navigation-context";
import { AddBackendModal } from "#/components/features/backends/add-backend-modal";
import * as telemetry from "#/services/telemetry";
const getServerInfoMock = vi.hoisted(() => vi.fn());
@@ -21,14 +23,7 @@ vi.mock("@openhands/typescript-client/clients", () => ({
}),
}));
// Mock the services useTracking depends on (PostHog client + settings) so the
// consent gate is open and captured events are observable. useTracking itself
// is never mocked.
const captureMock = vi.hoisted(() => vi.fn());
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({ capture: captureMock }),
}));
let captureMock: MockInstance<typeof telemetry.trackEvent>;
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => ({
@@ -57,14 +52,15 @@ function renderWithProviders(
}
beforeEach(() => {
captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined);
window.localStorage.clear();
getServerInfoMock.mockReset();
getServerInfoMock.mockResolvedValue({ version: "1.28.0" });
captureMock.mockClear();
__resetActiveStoreForTests();
});
afterEach(() => {
captureMock.mockRestore();
window.localStorage.clear();
__resetActiveStoreForTests();
});
@@ -25,6 +25,7 @@ import {
getCloudOrganizationMe,
getCurrentCloudApiKey,
} from "#/api/cloud/organization-service.api";
import * as telemetry from "#/services/telemetry";
const deviceFlowMocks = vi.hoisted(() => ({
startDeviceFlow: vi.fn(),
@@ -63,14 +64,7 @@ vi.mock("#/api/device-flow-client", async (importOriginal) => {
};
});
// Mock the services useTracking depends on (PostHog client + settings) so the
// consent gate is open and captured events are observable. useTracking itself
// is never mocked.
const captureMock = vi.hoisted(() => vi.fn());
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({ capture: captureMock }),
}));
let captureMock: ReturnType<typeof vi.spyOn>;
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => ({
@@ -104,6 +98,8 @@ function TestSeed({
}
beforeEach(() => {
captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined);
vi.spyOn(telemetry, "isTelemetryEnabled").mockReturnValue(true);
window.localStorage.clear();
getServerInfoMock.mockReset();
getServerInfoMock.mockResolvedValue({ version: "1.28.0" });
@@ -137,7 +133,6 @@ beforeEach(() => {
});
deviceFlowMocks.pollForToken.mockReset();
deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => {}));
captureMock.mockClear();
__resetActiveStoreForTests();
__resetHealthStoreForTests();
});
@@ -69,7 +69,6 @@ function buildSettings(overrides: Partial<Settings> = {}): Settings {
function buildConfig(): WebClientConfig {
return {
posthog_client_key: null,
feature_flags: { hide_llm_settings: false, hide_users_page: true },
providers_configured: [],
maintenance_start_time: null,
@@ -26,7 +26,6 @@ function buildSettings(overrides: Partial<Settings> = {}): Settings {
function buildConfig(hideLlmSettings = false): WebClientConfig {
return {
posthog_client_key: null,
feature_flags: {
hide_llm_settings: hideLlmSettings,
hide_users_page: true,
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MockInstance } from "vitest";
import {
__resetActiveStoreForTests,
@@ -17,16 +18,11 @@ import { NavigationProvider } from "#/context/navigation-context";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SecretsService } from "#/api/secrets-service";
import { DEFAULT_SETTINGS } from "#/services/settings";
import * as telemetry from "#/services/telemetry";
const llmSettingsScreenMock = vi.hoisted(() => vi.fn());
const getServerInfoMock = vi.hoisted(() => vi.fn());
const captureMock = vi.hoisted(() => vi.fn());
// useTracking depends on PostHog's `usePostHog`. Mock that underlying service
// (not useTracking itself) so the onboarding analytics events can be asserted.
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({ capture: captureMock }),
}));
let captureMock: MockInstance<typeof telemetry.trackEvent>;
// Both the backend status badge in the embedded edit form and the
// step-1 health probe ride on `useBackendsHealth`, which resolves
@@ -200,6 +196,7 @@ function renderModal(onClose = vi.fn()) {
}
beforeEach(() => {
captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined);
window.localStorage.clear();
window.sessionStorage.clear();
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
@@ -235,6 +232,7 @@ beforeEach(() => {
vi.spyOn(SecretsService, "createSecret").mockResolvedValue();
});
afterEach(() => {
captureMock.mockRestore();
window.localStorage.clear();
window.sessionStorage.clear();
vi.unstubAllEnvs();
@@ -1,187 +0,0 @@
import { type ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PostHogWrapper } from "#/components/providers/posthog-wrapper";
import OptionService from "#/api/option-service/option-service.api";
// Mock PostHogProvider to capture the options passed to it
const mockPostHogProvider = vi.fn();
vi.mock("posthog-js/react", () => ({
PostHogProvider: (props: Record<string, unknown>) => {
mockPostHogProvider(props);
return props.children;
},
}));
const renderWithQueryClient = (children: ReactNode) =>
render(
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
}
>
{children}
</QueryClientProvider>,
);
describe("PostHogWrapper", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset URL hash
window.location.hash = "";
// Clear sessionStorage
sessionStorage.clear();
// Mock the config fetch
// @ts-expect-error - partial mock
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
posthog_client_key: "test-posthog-key",
});
});
it("should initialize PostHog with bootstrap IDs from URL hash (without ph_ prefix)", async () => {
// Webflow sends distinct_id and session_id without the ph_ prefix
window.location.hash = "distinct_id=user-123&session_id=session-456";
renderWithQueryClient(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: {
distinctID: "user-123",
sessionID: "session-456",
},
}),
}),
);
});
it("should clean up URL hash after extracting bootstrap IDs", async () => {
window.location.hash = "distinct_id=user-123&session_id=session-456";
renderWithQueryClient(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(window.location.hash).toBe("");
});
it("should persist bootstrap IDs to sessionStorage for OAuth survival", async () => {
window.location.hash = "distinct_id=user-123&session_id=session-456";
renderWithQueryClient(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
// After extracting from hash, IDs should NOT remain in sessionStorage
// because they were already consumed during this page load.
// But if a full-page redirect happened before PostHog init,
// sessionStorage would still have them for the next load.
// We verify the write happened by checking the provider received the IDs.
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: {
distinctID: "user-123",
sessionID: "session-456",
},
}),
}),
);
});
it("should read bootstrap IDs from sessionStorage when hash is absent (post-OAuth)", async () => {
// Simulate returning from OAuth: no hash, but sessionStorage has the IDs
sessionStorage.setItem(
"posthog_bootstrap",
JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }),
);
renderWithQueryClient(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: {
distinctID: "user-123",
sessionID: "session-456",
},
}),
}),
);
});
it("should clean up sessionStorage after consuming bootstrap IDs", async () => {
sessionStorage.setItem(
"posthog_bootstrap",
JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }),
);
renderWithQueryClient(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull();
});
it("should initialize without bootstrap when neither hash nor sessionStorage has IDs", async () => {
renderWithQueryClient(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: undefined,
}),
}),
);
});
it("keeps rendering children when config fetch fails", async () => {
vi.spyOn(OptionService, "getConfig").mockRejectedValueOnce(new Error("boom"));
renderWithQueryClient(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(mockPostHogProvider).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { TelemetryProvider } from "#/components/providers/telemetry-provider";
import * as telemetry from "#/services/telemetry";
const runtimeConfig = {
apiKey: "phc_embedded",
apiHost: "https://events.example.com",
uiHost: "https://posthog.example.com",
};
describe("TelemetryProvider", () => {
let configureBootstrapMock: ReturnType<typeof vi.spyOn>;
let configureTelemetryMock: ReturnType<typeof vi.spyOn>;
let initializeClientMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
configureBootstrapMock = vi
.spyOn(telemetry, "configurePostHogBootstrap")
.mockImplementation(() => undefined);
configureTelemetryMock = vi
.spyOn(telemetry, "configureTelemetry")
.mockImplementation(() => undefined);
initializeClientMock = vi
.spyOn(telemetry, "initializePostHogClient")
.mockResolvedValue(null);
window.location.hash = "";
sessionStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("configures telemetry and bootstraps IDs from the URL", () => {
window.location.hash = "distinct_id=user-123&session_id=session-456";
render(
<TelemetryProvider config={runtimeConfig}>
<div data-testid="child" />
</TelemetryProvider>,
);
expect(configureTelemetryMock).toHaveBeenCalledWith(runtimeConfig);
expect(configureBootstrapMock).toHaveBeenCalledWith({
distinctID: "user-123",
sessionID: "session-456",
});
expect(window.location.hash).toBe("");
});
it("restores valid bootstrap IDs after OAuth and discards malformed data", () => {
sessionStorage.setItem("posthog_bootstrap", "not-json");
const view = render(
<TelemetryProvider config={runtimeConfig}>
<div />
</TelemetryProvider>,
);
expect(configureBootstrapMock).toHaveBeenCalledWith(undefined);
expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull();
view.unmount();
configureBootstrapMock.mockClear();
sessionStorage.setItem(
"posthog_bootstrap",
JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }),
);
render(
<TelemetryProvider config={runtimeConfig}>
<div />
</TelemetryProvider>,
);
expect(configureBootstrapMock).toHaveBeenCalledWith({
distinctID: "user-123",
sessionID: "session-456",
});
});
it("keeps rendering children when eager initialization fails", async () => {
initializeClientMock.mockRejectedValueOnce(new Error("unavailable"));
render(
<TelemetryProvider config={runtimeConfig}>
<div data-testid="child" />
</TelemetryProvider>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
await waitFor(() => expect(initializeClientMock).toHaveBeenCalledOnce());
});
it("does not initialize when analytics are disabled", () => {
render(
<TelemetryProvider config={false}>
<div data-testid="child" />
</TelemetryProvider>,
);
expect(screen.getByTestId("child")).toBeInTheDocument();
expect(configureTelemetryMock).toHaveBeenCalledWith(false);
expect(initializeClientMock).not.toHaveBeenCalled();
});
});
+25 -3
View File
@@ -14,17 +14,39 @@ function read(rel: string): string {
}
describe("npm publish workflow", () => {
it("builds the packaged static app with production PostHog configuration", () => {
it("builds both package surfaces with the production PostHog key", () => {
const workflow = read(".github/workflows/npm-publish.yml");
const buildAppStep = workflow.match(
/- name: Build app[\s\S]*?(?=\n\s*- name: Build library)/,
)?.[0];
const buildLibraryStep = workflow.match(
/- name: Build library[\s\S]*?(?=\n\s*- name: Verify package contents)/,
)?.[0];
expect(buildAppStep).toBeTruthy();
expect(buildAppStep).toContain("VITE_APP_ENV: production");
expect(buildAppStep).toContain(
"VITE_POSTHOG_CLIENT_KEY: ${{ vars.POSTHOG_PROD_KEY }}",
"VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }}",
);
expect(buildAppStep).toContain("npm run build");
expect(buildLibraryStep).toContain(
"VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }}",
);
expect(buildLibraryStep).toContain("npm run build:lib");
});
it("passes the selected PostHog key through Docker's supported build arg", () => {
const workflow = read(".github/workflows/docker.yml");
const dockerfile = read("docker/Dockerfile");
expect(workflow).toContain(
"VITE_POSTHOG_API_KEY=${{ steps.prep.outputs.posthog_api_key }}",
);
expect(workflow).not.toContain("VITE_POSTHOG_CLIENT_KEY");
expect(workflow).not.toContain("vite_app_env");
expect(dockerfile).toContain('ARG VITE_POSTHOG_API_KEY=""');
expect(dockerfile).toContain(
"ENV VITE_POSTHOG_API_KEY=${VITE_POSTHOG_API_KEY}",
);
expect(dockerfile).not.toContain("VITE_APP_ENV");
});
});
@@ -1,8 +1,9 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
import * as telemetry from "#/services/telemetry";
const mockNavigate = vi.fn();
@@ -39,15 +40,6 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
TOAST_OPTIONS: { position: "top-right" },
}));
// /new fires conversation_created through the real useTracking hook. Mock the
// lower-level deps (posthog + settings) rather than useTracking itself so the
// emitted payload can be asserted.
const { captureMock } = vi.hoisted(() => ({ captureMock: vi.fn() }));
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({ capture: captureMock }),
}));
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => ({
data: { email: "user@example.com", user_consents_to_analytics: true },
@@ -100,14 +92,22 @@ function makeStartTask(overrides: Record<string, unknown> = {}) {
describe("useNewConversationCommand", () => {
let queryClient: QueryClient;
let captureMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
captureMock = vi
.spyOn(telemetry, "trackEvent")
.mockResolvedValue(undefined);
queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
});
afterEach(() => {
captureMock.mockRestore();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
@@ -29,6 +29,7 @@ import type {
AutomationsResponse,
AutomationRunsResponse,
} from "#/types/automation";
import * as telemetry from "#/services/telemetry";
vi.mock("#/api/automation-service/automation-service.api", () => ({
default: {
@@ -42,10 +43,7 @@ vi.mock("#/api/automation-service/automation-service.api", () => ({
},
}));
const captureMock = vi.fn();
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({ capture: captureMock }),
}));
let captureMock: ReturnType<typeof vi.spyOn>;
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => ({ data: { user_consents_to_analytics: true } }),
@@ -108,6 +106,7 @@ function makeWrapper() {
}
beforeEach(() => {
captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined);
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(AutomationService.getAutomations).mockReset();
@@ -123,7 +122,6 @@ beforeEach(() => {
vi.mocked(AutomationService.deleteAutomation).mockResolvedValue(undefined);
vi.mocked(AutomationService.updateAutomation).mockResolvedValue(automation);
vi.mocked(AutomationService.toggleAutomation).mockResolvedValue(automation);
captureMock.mockClear();
vi.mocked(AutomationService.getAutomations).mockResolvedValue(listResponse);
vi.mocked(AutomationService.getAutomation).mockResolvedValue(automation);
@@ -132,6 +130,7 @@ beforeEach(() => {
});
afterEach(() => {
captureMock.mockRestore();
window.localStorage.clear();
__resetActiveStoreForTests();
});
@@ -1,189 +0,0 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const fakePosthog = {
identify: vi.fn(),
reset: vi.fn(),
};
let posthogInstance: typeof fakePosthog | null = fakePosthog;
vi.mock("posthog-js/react", () => ({
usePostHog: () => posthogInstance,
}));
const useSettingsMock = vi.fn();
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => useSettingsMock(),
}));
const useActiveBackendMock = vi.fn();
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => useActiveBackendMock(),
}));
const useCloudCurrentUserIdMock = vi.fn();
vi.mock("#/hooks/query/use-cloud-current-user-id", () => ({
useCloudCurrentUserId: () => useCloudCurrentUserIdMock(),
}));
import { usePostHogIdentify } from "#/hooks/use-posthog-identify";
const BACKEND_ID = "cloud-1";
const cloudBackend = { kind: "cloud" as const, id: BACKEND_ID };
const localBackend = { kind: "local" as const, id: "local-1" };
describe("usePostHogIdentify", () => {
beforeEach(() => {
vi.clearAllMocks();
posthogInstance = fakePosthog;
useActiveBackendMock.mockReturnValue({ backend: cloudBackend });
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true, email: "user@example.com" },
});
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: "user-123", isLoading: false },
});
});
describe("no-op guards", () => {
it("does nothing when backend is local mode", () => {
useActiveBackendMock.mockReturnValue({ backend: localBackend });
renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).not.toHaveBeenCalled();
expect(fakePosthog.reset).not.toHaveBeenCalled();
});
it("does nothing when posthog is not available", () => {
posthogInstance = null;
renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).not.toHaveBeenCalled();
expect(fakePosthog.reset).not.toHaveBeenCalled();
});
it("does nothing while settings are still loading (data === undefined)", () => {
useSettingsMock.mockReturnValue({ data: undefined });
renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).not.toHaveBeenCalled();
expect(fakePosthog.reset).not.toHaveBeenCalled();
});
it("does nothing when consent is null (decision not yet made)", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: null, email: "user@example.com" },
});
renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).not.toHaveBeenCalled();
expect(fakePosthog.reset).not.toHaveBeenCalled();
});
it("does nothing when consent is true but userId has not loaded yet", () => {
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: null, isLoading: true },
});
renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).not.toHaveBeenCalled();
expect(fakePosthog.reset).not.toHaveBeenCalled();
});
});
describe("identify", () => {
it("calls posthog.identify with userId and email when consent is true", () => {
renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).toHaveBeenCalledOnce();
expect(fakePosthog.identify).toHaveBeenCalledWith("user-123", {
email: "user@example.com",
});
});
it("omits email from traits when settings.email is not set", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true, email: undefined },
});
renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).toHaveBeenCalledWith("user-123", {
email: undefined,
});
});
it("re-identifies with updated traits when email changes", () => {
const { rerender } = renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).toHaveBeenCalledOnce();
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true, email: "new@example.com" },
});
rerender();
expect(fakePosthog.identify).toHaveBeenCalledTimes(2);
expect(fakePosthog.identify).toHaveBeenLastCalledWith("user-123", {
email: "new@example.com",
});
});
});
describe("reset", () => {
it("calls posthog.reset when consent is explicitly false", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false, email: "user@example.com" },
});
renderHook(() => usePostHogIdentify());
expect(fakePosthog.reset).toHaveBeenCalledOnce();
expect(fakePosthog.identify).not.toHaveBeenCalled();
});
it("calls posthog.reset when userId becomes null after a prior identify (logout)", () => {
const { rerender } = renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).toHaveBeenCalledOnce();
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: null, isLoading: false },
});
rerender();
expect(fakePosthog.reset).toHaveBeenCalledOnce();
});
it("does not call posthog.reset on initial render when userId is null with no prior identify", () => {
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: null, isLoading: false },
});
renderHook(() => usePostHogIdentify());
expect(fakePosthog.reset).not.toHaveBeenCalled();
expect(fakePosthog.identify).not.toHaveBeenCalled();
});
it("calls posthog.reset when consent changes from true to false after identify", () => {
const { rerender } = renderHook(() => usePostHogIdentify());
expect(fakePosthog.identify).toHaveBeenCalledOnce();
fakePosthog.identify.mockClear();
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false, email: "user@example.com" },
});
rerender();
expect(fakePosthog.reset).toHaveBeenCalledOnce();
expect(fakePosthog.identify).not.toHaveBeenCalled();
});
});
});
@@ -38,7 +38,6 @@ vi.mock("#/hooks/use-active-agent-profile", () => ({
const createConfig = (
feature_flags: Partial<WebClientConfig["feature_flags"]> = {},
): WebClientConfig => ({
posthog_client_key: null,
feature_flags: {
hide_llm_settings: false,
hide_users_page: true,
@@ -1,118 +0,0 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// usePostHog must be controllable per-test so use vi.hoisted.
const { usePostHogMock } = vi.hoisted(() => ({
usePostHogMock: vi.fn(),
}));
vi.mock("posthog-js/react", () => ({
usePostHog: usePostHogMock,
}));
const useSettingsMock = vi.fn();
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => useSettingsMock(),
}));
const handleCaptureConsentMock = vi.fn();
vi.mock("#/utils/handle-capture-consent", () => ({
handleCaptureConsent: (...args: unknown[]) =>
handleCaptureConsentMock(...args),
}));
// Import after mocks so the module sees the stubbed dependencies.
import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent";
const fakePosthog = { capture: vi.fn() };
describe("useSyncPostHogConsent", () => {
beforeEach(() => {
vi.clearAllMocks();
usePostHogMock.mockReturnValue(fakePosthog);
});
it("calls opt-out when user_consents_to_analytics is null", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: null },
});
renderHook(() => useSyncPostHogConsent());
expect(handleCaptureConsentMock).toHaveBeenCalledWith(fakePosthog, false);
});
it("calls opt-out when user_consents_to_analytics is false", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false },
});
renderHook(() => useSyncPostHogConsent());
expect(handleCaptureConsentMock).toHaveBeenCalledWith(fakePosthog, false);
});
it("calls opt-in when user_consents_to_analytics is true", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true },
});
renderHook(() => useSyncPostHogConsent());
expect(handleCaptureConsentMock).toHaveBeenCalledWith(fakePosthog, true);
});
it("does nothing while settings are still loading (data === undefined)", () => {
useSettingsMock.mockReturnValue({ data: undefined });
renderHook(() => useSyncPostHogConsent());
expect(handleCaptureConsentMock).not.toHaveBeenCalled();
});
it("does nothing when posthog is not yet available", () => {
usePostHogMock.mockReturnValue(null);
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true },
});
renderHook(() => useSyncPostHogConsent());
expect(handleCaptureConsentMock).not.toHaveBeenCalled();
});
it("re-syncs when settings update from null to true (consent granted after load)", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: null },
});
const { rerender } = renderHook(() => useSyncPostHogConsent());
expect(handleCaptureConsentMock).toHaveBeenCalledWith(fakePosthog, false);
handleCaptureConsentMock.mockClear();
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true },
});
rerender();
expect(handleCaptureConsentMock).toHaveBeenCalledWith(fakePosthog, true);
});
it("re-syncs when settings update from true to false (consent revoked)", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true },
});
const { rerender } = renderHook(() => useSyncPostHogConsent());
handleCaptureConsentMock.mockClear();
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false },
});
rerender();
expect(handleCaptureConsentMock).toHaveBeenCalledWith(fakePosthog, false);
});
});
@@ -0,0 +1,184 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const useSettingsMock = vi.fn();
const saveSettingsMock = vi.fn();
const useSaveSettingsMock = vi.fn();
const clearPendingCloudTelemetryConsentMock = vi.fn();
const setTelemetryConsentMock = vi.fn();
const state = {
backendId: "backend-1",
backendKind: "cloud" as "cloud" | "local",
pendingConsent: null as "granted" | "denied" | null,
isSavingSettings: false,
};
let pendingConsentListener: (() => void) | null = null;
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => useSettingsMock(),
}));
vi.mock("#/hooks/mutation/use-save-settings", () => ({
useSaveSettings: (...args: unknown[]) => {
useSaveSettingsMock(...args);
return {
mutate: saveSettingsMock,
isPending: state.isSavingSettings,
};
},
}));
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => ({
backend: { id: state.backendId, kind: state.backendKind },
}),
}));
vi.mock("#/services/telemetry", () => ({
clearPendingCloudTelemetryConsent: (...args: unknown[]) =>
clearPendingCloudTelemetryConsentMock(...args),
getPendingCloudTelemetryConsent: () => state.pendingConsent,
setTelemetryConsent: (...args: unknown[]) => setTelemetryConsentMock(...args),
subscribeTelemetryConsent: (listener: () => void) => {
pendingConsentListener = listener;
return () => {
pendingConsentListener = null;
};
},
}));
// Import after mocks so the module sees the stubbed dependencies.
import { useSyncTelemetryConsent } from "#/hooks/use-sync-telemetry-consent";
describe("useSyncTelemetryConsent", () => {
beforeEach(() => {
vi.clearAllMocks();
state.backendId = "backend-1";
state.backendKind = "cloud";
state.pendingConsent = null;
state.isSavingSettings = false;
pendingConsentListener = null;
});
it("calls opt-out when user_consents_to_analytics is null", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: null },
});
renderHook(() => useSyncTelemetryConsent());
expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied", {
syncToCloud: false,
});
});
it("calls opt-out when user_consents_to_analytics is false", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false },
});
renderHook(() => useSyncTelemetryConsent());
expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied", {
syncToCloud: false,
});
});
it("calls opt-in when user_consents_to_analytics is true", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true },
});
renderHook(() => useSyncTelemetryConsent());
expect(setTelemetryConsentMock).toHaveBeenCalledWith("granted", {
syncToCloud: false,
});
});
it("does nothing while settings are still loading (data === undefined)", () => {
useSettingsMock.mockReturnValue({ data: undefined });
renderHook(() => useSyncTelemetryConsent());
expect(setTelemetryConsentMock).not.toHaveBeenCalled();
});
it("persists a newer pre-login grant instead of applying a stale backend denial", () => {
state.pendingConsent = "granted";
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false },
});
renderHook(() => useSyncTelemetryConsent());
expect(saveSettingsMock).toHaveBeenCalledWith({
user_consents_to_analytics: true,
});
expect(setTelemetryConsentMock).not.toHaveBeenCalled();
expect(clearPendingCloudTelemetryConsentMock).not.toHaveBeenCalled();
expect(useSaveSettingsMock).toHaveBeenCalledWith("personal", { retry: 2 });
});
it("clears a pending browser choice only after the backend confirms it", () => {
state.pendingConsent = "granted";
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true },
});
renderHook(() => useSyncTelemetryConsent());
expect(clearPendingCloudTelemetryConsentMock).toHaveBeenCalledWith(
"granted",
);
expect(saveSettingsMock).not.toHaveBeenCalled();
expect(setTelemetryConsentMock).not.toHaveBeenCalled();
});
it("keeps the choice pending when only a local backend confirms it", () => {
state.backendKind = "local";
state.pendingConsent = "granted";
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: true },
});
renderHook(() => useSyncTelemetryConsent());
expect(clearPendingCloudTelemetryConsentMock).not.toHaveBeenCalled();
expect(saveSettingsMock).not.toHaveBeenCalled();
});
it("does not start another mutation while bounded retries run", () => {
state.pendingConsent = "granted";
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false },
});
const { rerender } = renderHook(() => useSyncTelemetryConsent());
state.isSavingSettings = true;
rerender();
state.isSavingSettings = false;
rerender();
expect(saveSettingsMock).toHaveBeenCalledTimes(1);
});
it("reacts when a first-run choice is made after the hook has mounted", () => {
useSettingsMock.mockReturnValue({
data: { user_consents_to_analytics: false },
});
renderHook(() => useSyncTelemetryConsent());
expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied", {
syncToCloud: false,
});
vi.clearAllMocks();
state.pendingConsent = "granted";
act(() => pendingConsentListener?.());
expect(saveSettingsMock).toHaveBeenCalledWith({
user_consents_to_analytics: true,
});
expect(setTelemetryConsentMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,105 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const useSettingsMock = vi.fn();
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => useSettingsMock(),
}));
const useActiveBackendMock = vi.fn();
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => useActiveBackendMock(),
}));
const useCloudCurrentUserIdMock = vi.fn();
vi.mock("#/hooks/query/use-cloud-current-user-id", () => ({
useCloudCurrentUserId: () => useCloudCurrentUserIdMock(),
}));
const setTelemetryIdentityMock = vi.fn();
vi.mock("#/services/telemetry", () => ({
setTelemetryIdentity: (...args: unknown[]) =>
setTelemetryIdentityMock(...args),
}));
import { useTelemetryIdentity } from "#/hooks/use-telemetry-identity";
const BACKEND_ID = "cloud-1";
const cloudBackend = { kind: "cloud" as const, id: BACKEND_ID };
const localBackend = { kind: "local" as const, id: "local-1" };
describe("useTelemetryIdentity", () => {
beforeEach(() => {
vi.clearAllMocks();
useActiveBackendMock.mockReturnValue({ backend: cloudBackend });
useSettingsMock.mockReturnValue({
data: { email: "user@example.com", git_user_email: "git@example.com" },
});
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: "user-123", isLoading: false },
});
});
it("declares the current Cloud identity", () => {
renderHook(() => useTelemetryIdentity());
expect(setTelemetryIdentityMock).toHaveBeenCalledWith("user-123", {
email: "user@example.com",
});
});
it("falls back to the git email and omits an absent email", () => {
useSettingsMock.mockReturnValue({
data: { email: "", git_user_email: "git@example.com" },
});
const { rerender } = renderHook(() => useTelemetryIdentity());
expect(setTelemetryIdentityMock).toHaveBeenLastCalledWith("user-123", {
email: "git@example.com",
});
useSettingsMock.mockReturnValue({ data: {} });
rerender();
expect(setTelemetryIdentityMock).toHaveBeenLastCalledWith("user-123", {});
});
it("waits until the Cloud identity query settles", () => {
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: null, isLoading: true },
});
renderHook(() => useTelemetryIdentity());
expect(setTelemetryIdentityMock).not.toHaveBeenCalled();
});
it("declares logout only after the Cloud identity query settles", () => {
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: null, isLoading: false },
});
renderHook(() => useTelemetryIdentity());
expect(setTelemetryIdentityMock).toHaveBeenCalledWith(null);
});
it("preserves Cloud identity while a local backend is active", () => {
useActiveBackendMock.mockReturnValue({ backend: localBackend });
renderHook(() => useTelemetryIdentity());
expect(setTelemetryIdentityMock).not.toHaveBeenCalled();
});
it("declares a changed Cloud account", () => {
const { rerender } = renderHook(() => useTelemetryIdentity());
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: "user-456", isLoading: false },
});
rerender();
expect(setTelemetryIdentityMock).toHaveBeenLastCalledWith("user-456", {
email: "user@example.com",
});
});
});
+15 -2
View File
@@ -19,6 +19,7 @@ vi.mock("posthog-js", () => ({
}));
import { useTelemetry } from "#/hooks/use-telemetry";
import { setTelemetryConsent } from "#/services/telemetry";
describe("useTelemetry", () => {
beforeEach(() => {
@@ -115,6 +116,18 @@ describe("useTelemetry", () => {
expect(localStorage.getItem("openhands-telemetry-consent")).toBe("denied");
});
it("reacts to consent changes outside the hook", async () => {
const { result } = renderHook(() => useTelemetry());
await act(() =>
setTelemetryConsent("granted", {
syncToCloud: false,
}),
);
expect(result.current.consent).toBe("granted");
});
it("track function does nothing when consent is not granted", () => {
localStorage.setItem("openhands-telemetry-first-use", "true"); // Skip install tracking
vi.clearAllMocks();
@@ -155,8 +168,8 @@ describe("useTelemetry", () => {
expect(result.current.consent).toBe("granted");
act(() => {
result.current.clearData();
await act(async () => {
await result.current.clearData();
});
expect(result.current.consent).toBe("pending");
+42 -28
View File
@@ -1,14 +1,6 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const captureMock = vi.fn();
let posthogMock: { capture: typeof captureMock } | undefined = {
capture: captureMock,
};
vi.mock("posthog-js/react", () => ({
usePostHog: () => posthogMock,
}));
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as telemetry from "#/services/telemetry";
const useSettingsMock = vi.fn();
vi.mock("#/hooks/query/use-settings", () => ({
@@ -23,9 +15,13 @@ const TEST_EMAIL = "user@example.com";
let COMMON: { current_url: string; user_email: string };
describe("useTracking", () => {
let captureMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
posthogMock = { capture: captureMock };
captureMock = vi
.spyOn(telemetry, "trackEvent")
.mockResolvedValue(undefined);
useSettingsMock.mockReset();
useSettingsMock.mockReturnValue({
data: { email: TEST_EMAIL, user_consents_to_analytics: true },
});
@@ -35,6 +31,10 @@ describe("useTracking", () => {
};
});
afterEach(() => {
captureMock.mockRestore();
});
const getTracking = () => renderHook(() => useTracking()).result.current;
describe("trackLoginButtonClick", () => {
@@ -332,41 +332,55 @@ describe("useTracking", () => {
});
});
describe("consent gate", () => {
it("does not capture when posthog is not initialized", () => {
posthogMock = undefined;
getTracking().trackPushButtonClick();
expect(captureMock).not.toHaveBeenCalled();
});
it("does not capture when user_consents_to_analytics is false", () => {
describe("shared telemetry boundary", () => {
it("delegates consent enforcement when backend settings report false", () => {
useSettingsMock.mockReturnValue({
data: { email: TEST_EMAIL, user_consents_to_analytics: false },
});
getTracking().trackPushButtonClick();
expect(captureMock).not.toHaveBeenCalled();
expect(captureMock).toHaveBeenCalledWith(
"push_button_clicked",
expect.objectContaining(COMMON),
);
});
it("does not capture when user_consents_to_analytics is null", () => {
it("delegates consent enforcement when backend settings report null", () => {
useSettingsMock.mockReturnValue({
data: { email: TEST_EMAIL, user_consents_to_analytics: null },
});
getTracking().trackPushButtonClick();
expect(captureMock).not.toHaveBeenCalled();
expect(captureMock).toHaveBeenCalledWith(
"push_button_clicked",
expect.objectContaining(COMMON),
);
});
it("does not capture when settings are still loading", () => {
it("does not drop backend milestones while settings are loading", () => {
useSettingsMock.mockReturnValue({ data: undefined });
getTracking().trackPushButtonClick();
getTracking().trackBackendAdded({
backendKind: "cloud",
connectionMethod: "cloud_login",
isOpenhandsCloud: true,
isCustomHost: false,
hasApiKey: true,
source: "onboarding",
});
expect(captureMock).not.toHaveBeenCalled();
expect(captureMock).toHaveBeenCalledWith(
"backend_added",
expect.objectContaining({
backend_kind: "cloud",
connection_method: "cloud_login",
is_openhands_cloud: true,
source: "onboarding",
user_email: null,
}),
);
});
});
+1 -1
View File
@@ -92,4 +92,4 @@ describe("AppSettingsScreen", () => {
);
});
});
});
});
-6
View File
@@ -16,12 +16,6 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
vi.mock("posthog-js/react", () => ({
usePostHog: () => ({
capture: vi.fn(),
}),
}));
const RouterStub = createRoutesStub([
{
Component: DeviceVerify,
+4 -12
View File
@@ -6,7 +6,6 @@ import MainApp from "#/routes/root-layout";
const useConfigMock = vi.fn();
const useSettingsMock = vi.fn();
const migrateUserConsentMock = vi.fn();
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
@@ -16,18 +15,12 @@ vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => useSettingsMock(),
}));
vi.mock("#/hooks/use-migrate-user-consent", () => ({
useMigrateUserConsent: () => ({
migrateUserConsent: migrateUserConsentMock,
}),
vi.mock("#/hooks/use-sync-telemetry-consent", () => ({
useSyncTelemetryConsent: () => {},
}));
vi.mock("#/hooks/use-sync-posthog-consent", () => ({
useSyncPostHogConsent: () => {},
}));
vi.mock("#/hooks/use-posthog-identify", () => ({
usePostHogIdentify: () => {},
vi.mock("#/hooks/use-telemetry-identity", () => ({
useTelemetryIdentity: () => {},
}));
vi.mock("#/hooks/use-app-title", () => ({
@@ -134,7 +127,6 @@ describe("root layout", () => {
expect(
screen.queryByTestId("user-capture-consent-form"),
).not.toBeInTheDocument();
expect(migrateUserConsentMock).toHaveBeenCalled();
});
it("renders an identical root-layout className across routes so navigation never shifts the outer container", () => {
-4
View File
@@ -69,7 +69,6 @@ describe("settings route", () => {
it("redirects hidden OSS settings pages to the first available route", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
posthog_client_key: null,
feature_flags: {
hide_llm_settings: true,
hide_users_page: true,
@@ -94,7 +93,6 @@ describe("settings route", () => {
it("does not redirect unrelated removed nested paths through the settings loader", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
posthog_client_key: null,
feature_flags: {
hide_llm_settings: false,
hide_users_page: true,
@@ -146,7 +144,6 @@ describe("settings route", () => {
it("redirects to /settings/agents when ACP is active and the path is disabled-by-ACP", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
posthog_client_key: null,
feature_flags: {
hide_llm_settings: false,
hide_users_page: true,
@@ -179,7 +176,6 @@ describe("settings route", () => {
it("does not redirect when the active agent is OpenHands", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
posthog_client_key: null,
feature_flags: {
hide_llm_settings: false,
hide_users_page: true,
@@ -1,17 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
enabled: true,
trackEvent: vi.fn(),
}));
vi.mock("#/services/telemetry", () => ({
isTelemetryEnabled: () => mocks.enabled,
trackEvent: mocks.trackEvent,
}));
import {
trackCanvasBackendAdded,
trackCloudConversationReady,
trackCloudDeviceAuthorizationStarted,
trackCloudDeviceAuthorizationSucceeded,
@@ -19,7 +16,6 @@ import {
describe("cloud funnel analytics", () => {
beforeEach(() => {
mocks.enabled = true;
mocks.trackEvent.mockReset();
});
@@ -40,38 +36,20 @@ describe("cloud funnel analytics", () => {
is_openhands_cloud: true,
is_custom_host: false,
source: "onboarding",
client_source: "agent_canvas",
}),
);
expect(mocks.trackEvent).toHaveBeenNthCalledWith(
2,
"cloud_device_authorization_succeeded",
expect.objectContaining({ client_source: "agent_canvas" }),
expect.objectContaining({ source: "onboarding" }),
);
});
it("emits backend and ready milestones without raw host or credentials", () => {
trackCanvasBackendAdded({
backendKind: "cloud",
connectionMethod: "cloud_login",
host: "https://app.all-hands.dev",
hasApiKey: true,
source: "add_backend_modal",
});
it("emits the ready milestone without conversation content", () => {
trackCloudConversationReady("task-id", "conversation-id");
expect(mocks.trackEvent).toHaveBeenNthCalledWith(
1,
"backend_added",
expect.objectContaining({
backend_kind: "cloud",
connection_method: "cloud_login",
has_api_key: true,
}),
);
expect(mocks.trackEvent.mock.calls[0][1]).not.toHaveProperty("host");
expect(mocks.trackEvent).toHaveBeenNthCalledWith(
2,
"cloud_conversation_ready",
expect.objectContaining({
task_id: "task-id",
@@ -80,12 +58,17 @@ describe("cloud funnel analytics", () => {
);
});
it("does not enqueue funnel events without Canvas consent", () => {
mocks.enabled = false;
it("uses a stable insert ID to deduplicate polling consumers at ingestion", () => {
trackCloudConversationReady("task-dedupe", "conversation-dedupe");
trackCloudConversationReady("task-dedupe", "conversation-dedupe");
expect(
trackCloudDeviceAuthorizationStarted("https://app.all-hands.dev"),
).toBe(false);
expect(mocks.trackEvent).not.toHaveBeenCalled();
expect(mocks.trackEvent).toHaveBeenCalledTimes(2);
expect(mocks.trackEvent).toHaveBeenNthCalledWith(
2,
"cloud_conversation_ready",
expect.objectContaining({
$insert_id: "agent_canvas:cloud_conversation_ready:task-dedupe",
}),
);
});
});
+29 -45
View File
@@ -1,63 +1,47 @@
import { waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { gunzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import { server } from "#/mocks/node";
import { renderHook, waitFor } from "@testing-library/react";
import posthog from "posthog-js";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearTelemetryData,
getPostHogInstance,
initializePostHogClient,
setTelemetryConsent,
trackEvent,
trackInstall,
} from "#/services/telemetry";
import { useTracking } from "#/hooks/use-tracking";
vi.mock("#/hooks/query/use-settings", () => ({
useSettings: () => ({ data: undefined }),
}));
describe("Canvas telemetry delivery", () => {
afterEach(async () => {
await clearTelemetryData();
});
it("delivers a consented event through the real named PostHog client", async () => {
const requestBodies: string[] = [];
server.use(
http.post("https://z.openhands.dev/*", async ({ request }) => {
const body = Buffer.from(await request.arrayBuffer());
const compression = new URL(request.url).searchParams.get(
"compression",
);
requestBodies.push(
compression === "gzip-js"
? gunzipSync(body).toString("utf8")
: body.toString("utf8"),
);
return HttpResponse.json(null, { status: 200 });
}),
);
it("keeps install and backend-transition events on one client identity", async () => {
const client = await initializePostHogClient();
expect(client).not.toBeNull();
expect(client).not.toBe(posthog);
const capture = vi.spyOn(client!, "capture");
await trackInstall();
expect(capture).toHaveBeenCalledWith("canvas_install", expect.any(Object));
const installDistinctId = client!.get_distinct_id();
await setTelemetryConsent("granted");
const client = await getPostHogInstance();
expect(client).not.toBeNull();
const { result } = renderHook(() => useTracking());
// Reproduce the host-app lifecycle that previously broke Canvas events:
// its default client is initialized and opted out after Canvas opts in.
const { default: hostPosthog } = await import("posthog-js");
hostPosthog.init(client!.config.token, {
api_host: "https://z.openhands.dev",
advanced_disable_flags: true,
autocapture: false,
capture_pageview: false,
result.current.trackBackendAdded({
backendKind: "cloud",
connectionMethod: "cloud_login",
isOpenhandsCloud: true,
isCustomHost: false,
hasApiKey: true,
source: "onboarding",
});
hostPosthog.opt_out_capturing();
expect(hostPosthog.has_opted_out_capturing()).toBe(true);
expect(client!.has_opted_out_capturing()).toBe(false);
// Send this assertion event immediately and without compression so the
// test validates the SDK's actual HTTP delivery rather than a capture mock.
client!.set_config({ request_batching: false, disable_compression: true });
await trackEvent("canvas_delivery_test", { source: "vitest" });
await waitFor(() =>
expect(
requestBodies.some((body) => body.includes("canvas_delivery_test")),
).toBe(true),
expect(capture).toHaveBeenCalledWith("backend_added", expect.any(Object)),
);
expect(client!.get_distinct_id()).toBe(installDistinctId);
});
});
+232 -6
View File
@@ -1,14 +1,23 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
// Mock posthog-js before importing telemetry service
let identifiedUserId: string | undefined;
const mockPosthog = {
init: vi.fn(),
capture: vi.fn(),
captureException: vi.fn(),
opt_in_capturing: vi.fn(),
opt_out_capturing: vi.fn(),
has_opted_out_capturing: vi.fn(() => false),
reset: vi.fn(),
register: vi.fn(),
identify: vi.fn((userId: string) => {
identifiedUserId = userId;
}),
get_property: vi.fn((property: string) =>
property === "$user_id" ? identifiedUserId : undefined,
),
reset: vi.fn(() => {
identifiedUserId = undefined;
}),
};
mockPosthog.init.mockReturnValue(mockPosthog);
@@ -17,11 +26,19 @@ vi.mock("posthog-js", () => ({
}));
import {
clearPendingCloudTelemetryConsent,
configureTelemetry,
getTelemetryConsent,
getPendingCloudTelemetryConsent,
initializePostHogClient,
setTelemetryConsent,
setTelemetryIdentity,
subscribeTelemetryConsent,
isTelemetryEnabled,
trackInstall,
trackSessionStart,
trackEvent,
trackException,
clearTelemetryData,
} from "#/services/telemetry";
@@ -40,28 +57,112 @@ describe("Telemetry Service", () => {
sessionStorage.clear();
// Reset mock
vi.clearAllMocks();
identifiedUserId = undefined;
mockPosthog.has_opted_out_capturing.mockReturnValue(false);
});
afterEach(() => {
configureTelemetry({});
localStorage.clear();
sessionStorage.clear();
});
describe("PostHog ownership", () => {
it("uses an isolated named client for Canvas consent and events", async () => {
it("retries one named Canvas client with runtime configuration", async () => {
configureTelemetry({
apiKey: "phc_embedded",
apiHost: "https://events.example.com",
uiHost: "https://posthog.example.com",
});
configureTelemetry({
apiKey: undefined,
apiHost: undefined,
uiHost: undefined,
});
mockPosthog.init.mockReturnValueOnce(null).mockReturnValue(mockPosthog);
await setTelemetryConsent("granted");
await expect(initializePostHogClient()).resolves.toBe(mockPosthog);
expect(mockPosthog.init).toHaveBeenCalledTimes(2);
expect(mockPosthog.init).toHaveBeenCalledWith(
expect.any(String),
"phc_embedded",
expect.objectContaining({
api_host: expect.any(String),
ui_host: expect.any(String),
api_host: "https://events.example.com",
ui_host: "https://posthog.example.com",
persistence_name: "agent-canvas",
consent_persistence_name: "agent-canvas-consent",
}),
"agent-canvas",
);
expect(mockPosthog.opt_in_capturing).toHaveBeenCalled();
const config = mockPosthog.init.mock.calls[1][1];
expect(
config.before_send({
event: "test_event",
properties: { client_source: "incorrect", custom: "value" },
}),
).toEqual({
event: "test_event",
properties: expect.objectContaining({
client_source: "agent_canvas",
client_version: expect.any(String),
package_name: "@openhands/agent-canvas",
package_version: expect.any(String),
custom: "value",
}),
});
});
});
describe("identity", () => {
it("identifies a consented Cloud user", async () => {
await setTelemetryConsent("granted");
await setTelemetryIdentity("user-a", { email: "a@example.com" });
expect(mockPosthog.identify).toHaveBeenCalledWith("user-a", {
email: "a@example.com",
});
});
it("resets before switching Cloud accounts and restores consent", async () => {
await setTelemetryConsent("granted");
await setTelemetryIdentity("user-a");
vi.clearAllMocks();
await setTelemetryIdentity("user-b");
expect(mockPosthog.reset).toHaveBeenCalledWith(false);
expect(mockPosthog.opt_in_capturing).toHaveBeenCalledOnce();
expect(mockPosthog.identify).toHaveBeenCalledWith("user-b", {});
});
it("clears identity on logout without changing the device", async () => {
await setTelemetryConsent("granted");
await setTelemetryIdentity("user-a");
vi.clearAllMocks();
await setTelemetryIdentity(null);
expect(mockPosthog.reset).toHaveBeenCalledWith(false);
expect(mockPosthog.opt_in_capturing).toHaveBeenCalledOnce();
expect(mockPosthog.identify).not.toHaveBeenCalled();
});
it("removes identity on denial and reapplies it after consent returns", async () => {
await setTelemetryConsent("granted");
await setTelemetryIdentity("user-a");
vi.clearAllMocks();
await setTelemetryConsent("denied");
expect(mockPosthog.reset).toHaveBeenCalledWith(false);
expect(mockPosthog.opt_out_capturing).toHaveBeenCalled();
vi.clearAllMocks();
await setTelemetryConsent("granted");
expect(mockPosthog.identify).toHaveBeenCalledWith("user-a", {});
});
});
@@ -95,6 +196,55 @@ describe("Telemetry Service", () => {
"denied",
);
});
it("applies consent synchronously once the shared client is initialized", async () => {
await setTelemetryConsent("denied");
vi.clearAllMocks();
const update = setTelemetryConsent("granted");
expect(mockPosthog.opt_in_capturing).toHaveBeenCalledTimes(1);
await update;
});
it("marks an explicit pre-login choice for backend reconciliation", async () => {
const listener = vi.fn();
const unsubscribe = subscribeTelemetryConsent(listener);
await setTelemetryConsent("granted");
expect(getPendingCloudTelemetryConsent()).toBe("granted");
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
});
it("applies capture consent before notifying identity reconcilers", async () => {
const listener = vi.fn(() => {
expect(getTelemetryConsent()).toBe("granted");
expect(mockPosthog.opt_in_capturing).toHaveBeenCalledTimes(1);
});
const unsubscribe = subscribeTelemetryConsent(listener);
await setTelemetryConsent("granted");
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
});
it("does not mark consent mirrored from backend settings as pending", async () => {
await setTelemetryConsent("granted", { syncToCloud: false });
expect(getPendingCloudTelemetryConsent()).toBeNull();
});
it("only clears the pending decision it expects", async () => {
await setTelemetryConsent("granted");
clearPendingCloudTelemetryConsent("denied");
expect(getPendingCloudTelemetryConsent()).toBe("granted");
clearPendingCloudTelemetryConsent("granted");
expect(getPendingCloudTelemetryConsent()).toBeNull();
});
});
describe("isTelemetryEnabled", () => {
@@ -186,6 +336,67 @@ describe("Telemetry Service", () => {
button: "submit",
});
});
it("repairs a stale SDK opt-out before a consented custom event", async () => {
localStorage.setItem("openhands-telemetry-consent", "granted");
mockPosthog.has_opted_out_capturing.mockReturnValue(true);
await trackEvent("custom_action");
expect(mockPosthog.opt_in_capturing).toHaveBeenCalledTimes(1);
expect(mockPosthog.capture).toHaveBeenCalledWith("custom_action", {});
});
it("stops an initialized client when telemetry is disabled", async () => {
await setTelemetryConsent("granted");
vi.clearAllMocks();
configureTelemetry(false);
await trackEvent("custom_action");
expect(mockPosthog.opt_out_capturing).toHaveBeenCalledTimes(1);
expect(mockPosthog.capture).not.toHaveBeenCalled();
configureTelemetry({});
});
it("does not let a consent refresh override a hard disable", async () => {
await setTelemetryConsent("denied");
configureTelemetry(false);
vi.clearAllMocks();
await setTelemetryConsent("granted", { syncToCloud: false });
expect(mockPosthog.opt_in_capturing).not.toHaveBeenCalled();
configureTelemetry({});
});
});
describe("trackSessionStart", () => {
it("repairs a stale SDK opt-out before recording the session", async () => {
localStorage.setItem("openhands-telemetry-consent", "granted");
mockPosthog.has_opted_out_capturing.mockReturnValue(true);
await trackSessionStart();
expect(mockPosthog.opt_in_capturing).toHaveBeenCalledTimes(1);
expect(mockPosthog.capture).toHaveBeenCalledWith(
"canvas_new_session",
expect.any(Object),
);
});
});
describe("trackException", () => {
it("uses the consent-aware boundary", async () => {
await setTelemetryConsent("granted");
const error = new Error("failure");
await trackException(error, { error_source: "test" });
expect(mockPosthog.captureException).toHaveBeenCalledWith(error, {
error_source: "test",
});
});
});
describe("clearTelemetryData", () => {
@@ -196,7 +407,22 @@ describe("Telemetry Service", () => {
await clearTelemetryData();
expect(localStorage.getItem("openhands-telemetry-consent")).toBeNull();
expect(getPendingCloudTelemetryConsent()).toBeNull();
expect(localStorage.getItem("openhands-telemetry-first-use")).toBeNull();
expect(mockPosthog.reset).toHaveBeenCalledWith(true);
expect(mockPosthog.opt_out_capturing).toHaveBeenCalled();
});
it("falls back to opting out if the SDK cannot reset", async () => {
await setTelemetryConsent("granted");
mockPosthog.reset.mockImplementationOnce(() => {
throw new Error("reset failed");
});
vi.clearAllMocks();
await expect(clearTelemetryData()).resolves.toBeUndefined();
expect(mockPosthog.opt_out_capturing).toHaveBeenCalledOnce();
});
});
+5 -9
View File
@@ -1,11 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import posthog from "posthog-js";
import { trackError } from "#/utils/error-handler";
import { trackException } from "#/services/telemetry";
vi.mock("posthog-js", () => ({
default: {
captureException: vi.fn(),
},
vi.mock("#/services/telemetry", () => ({
trackException: vi.fn(),
}));
describe("Error Handler", () => {
@@ -22,12 +20,11 @@ describe("Error Handler", () => {
const error = {
message: "Test error",
source: "test",
posthog,
};
trackError(error);
expect(posthog.captureException).toHaveBeenCalledWith(
expect(trackException).toHaveBeenCalledWith(
new Error("Test error"),
{
error_source: "test",
@@ -43,12 +40,11 @@ describe("Error Handler", () => {
extra: "info",
details: { foo: "bar" },
},
posthog,
};
trackError(error);
expect(posthog.captureException).toHaveBeenCalledWith(
expect(trackException).toHaveBeenCalledWith(
new Error("Test error"),
{
error_source: "test",
@@ -1,44 +0,0 @@
import posthog from "posthog-js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
describe("handleCaptureConsent", () => {
const optInSpy = vi.spyOn(posthog, "opt_in_capturing");
const optOutSpy = vi.spyOn(posthog, "opt_out_capturing");
const hasOptedInSpy = vi.spyOn(posthog, "has_opted_in_capturing");
const hasOptedOutSpy = vi.spyOn(posthog, "has_opted_out_capturing");
afterEach(() => {
vi.clearAllMocks();
});
it("should opt out of of capturing", () => {
handleCaptureConsent(posthog, false);
expect(optOutSpy).toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
});
it("should opt in to capturing if the user consents", () => {
handleCaptureConsent(posthog, true);
expect(optInSpy).toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
});
it("should not opt in to capturing if the user is already opted in", () => {
hasOptedInSpy.mockReturnValueOnce(true);
handleCaptureConsent(posthog, true);
expect(optInSpy).not.toHaveBeenCalled();
expect(optOutSpy).not.toHaveBeenCalled();
});
it("should not opt out of capturing if the user is already opted out", () => {
hasOptedOutSpy.mockReturnValueOnce(true);
handleCaptureConsent(posthog, false);
expect(optOutSpy).not.toHaveBeenCalled();
expect(optInSpy).not.toHaveBeenCalled();
});
});
+4 -10
View File
@@ -36,19 +36,13 @@ RUN npm ci
COPY . .
# Build the static frontend.
# VITE_APP_ENV controls the PostHog telemetry key baked into the bundle:
# "production" → prod key (set by CI for tagged releases)
# anything else → staging key (default for PR / main / local builds)
# VITE_POSTHOG_CLIENT_KEY is the PostHog project key exposed via the web client
# config (option-service). Not a secret — it's a public, client-side key. CI
# passes the prod key for tagged releases and the staging key otherwise.
# VITE_POSTHOG_API_KEY is a public client key baked into the bundle. CI passes
# production for tagged releases and staging for PR/main images.
# VITE_BASE_PATH is baked into React Router/Vite so the Docker image serves
# Canvas under the Cloud subpath instead of a dedicated hostname.
ARG VITE_APP_ENV=""
ARG VITE_POSTHOG_CLIENT_KEY=""
ARG VITE_POSTHOG_API_KEY=""
ARG VITE_BASE_PATH="/canvas"
ENV VITE_APP_ENV=${VITE_APP_ENV}
ENV VITE_POSTHOG_CLIENT_KEY=${VITE_POSTHOG_CLIENT_KEY}
ENV VITE_POSTHOG_API_KEY=${VITE_POSTHOG_API_KEY}
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
RUN npm run build
+1 -1
View File
@@ -98,7 +98,7 @@
"typecheck:staged": "react-router typegen && npx tsc --noEmit --skipLibCheck",
"check-translation-completeness": "node scripts/check-translation-completeness.cjs",
"build:app": "npm run make-i18n && react-router build",
"build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true VITE_APP_ENV=production vite build && tsc -p tsconfig.lib.json",
"build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true vite build && tsc -p tsconfig.lib.json",
"build:docker": "node scripts/docker-build.mjs",
"example:acp-docker:env": "node scripts/gen-acp-docker-env.mjs"
},
@@ -31,8 +31,6 @@ class OptionService {
await loadAgentServerInfo();
return {
posthog_client_key:
(import.meta.env.VITE_POSTHOG_CLIENT_KEY as string | undefined) ?? null,
feature_flags: {
hide_llm_settings: false,
hide_users_page: true,
-1
View File
@@ -23,7 +23,6 @@ export interface WebClientFeatureFlags {
}
export interface WebClientConfig {
posthog_client_key: string | null;
feature_flags: WebClientFeatureFlags;
providers_configured: Provider[];
maintenance_start_time: string | null;
@@ -16,10 +16,7 @@ import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { useNavigation } from "#/context/navigation-context";
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
import { useTracking } from "#/hooks/use-tracking";
import {
trackCanvasBackendAdded,
type CloudConnectionSource,
} from "#/services/cloud-funnel-analytics";
import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import { getLockedCloudHost } from "#/api/agent-server-config";
import { isOpenHandsCloudHost } from "#/api/device-flow-client";
@@ -1067,23 +1064,14 @@ function AddBackendConnectionOptions({
addBackend(payload);
// Coarse, non-sensitive host classification — never emit the raw host.
const isOpenHandsCloud = isOpenHandsCloudHost(payload.host);
const trackedByCanvas = trackCanvasBackendAdded({
trackBackendAdded({
backendKind: payload.kind,
connectionMethod,
host: payload.host,
isOpenhandsCloud: isOpenHandsCloud,
isCustomHost: !isOpenHandsCloud,
hasApiKey: Boolean(payload.apiKey),
source,
});
if (!trackedByCanvas) {
trackBackendAdded({
backendKind: payload.kind,
connectionMethod,
isOpenhandsCloud: isOpenHandsCloud,
isCustomHost: !isOpenHandsCloud,
hasApiKey: Boolean(payload.apiKey),
source,
});
}
redirectAfterAdd();
onClose();
},
@@ -18,7 +18,8 @@ import {
import { BrandButton } from "#/components/features/settings/brand-button";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
import { trackCanvasBackendAdded } from "#/services/cloud-funnel-analytics";
import { useTracking } from "#/hooks/use-tracking";
import { isOpenHandsCloudHost } from "#/api/device-flow-client";
import { I18nKey } from "#/i18n/declaration";
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
import { cn } from "#/utils/utils";
@@ -121,6 +122,7 @@ export function CheckBackendStep({
const { t } = useTranslation("openhands");
const { active, addBackend, setActive, updateBackend } =
useActiveBackendContext();
const { trackBackendAdded } = useTracking();
const { backend } = active;
const noBackendSelected = isNoBackend(backend);
const lockedCloudHost = getLockedCloudHost();
@@ -190,10 +192,12 @@ export function CheckBackendStep({
setActive(backend.id, null);
}
}
trackCanvasBackendAdded({
const isOpenHandsCloud = isOpenHandsCloudHost(payload.host);
trackBackendAdded({
backendKind: payload.kind,
connectionMethod,
host: payload.host,
isOpenhandsCloud: isOpenHandsCloud,
isCustomHost: !isOpenHandsCloud,
hasApiKey: Boolean(payload.apiKey),
source: "onboarding",
});
@@ -218,6 +222,7 @@ export function CheckBackendStep({
onClose,
onNext,
setActive,
trackBackendAdded,
updateBackend,
],
);
@@ -14,16 +14,19 @@ import {
setI18n,
} from "#/i18n";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { PostHogWrapper } from "./posthog-wrapper";
import type { TelemetryConfig } from "#/services/telemetry";
import { TelemetryProvider } from "./telemetry-provider";
import {
AgentServerUIRoot,
type AgentServerUIRootProps,
} from "./agent-server-ui-root";
export interface AgentServerUIPostHogAnalyticsConfig extends TelemetryConfig {
provider: "posthog";
}
export type AgentServerUIAnalyticsConfig =
| {
provider: "posthog";
}
| AgentServerUIPostHogAnalyticsConfig
| false
| null;
@@ -84,12 +87,17 @@ export function AgentServerUIProviders({
[],
);
const content =
analytics && analytics.provider === "posthog" ? (
<PostHogWrapper>{children}</PostHogWrapper>
) : (
children
);
const posthogConfig =
analytics && analytics.provider === "posthog"
? {
apiKey: analytics.apiKey,
apiHost: analytics.apiHost,
uiHost: analytics.uiHost,
}
: false;
const content = (
<TelemetryProvider config={posthogConfig}>{children}</TelemetryProvider>
);
const wrappedContent = withStyleRoot ? (
<AgentServerUIRoot
+1 -1
View File
@@ -2,10 +2,10 @@ export {
AgentServerUIProviders,
DEFAULT_AGENT_SERVER_ANALYTICS,
type AgentServerUIAnalyticsConfig,
type AgentServerUIPostHogAnalyticsConfig,
type AgentServerUIProvidersProps,
} from "./agent-server-ui-providers";
export {
AgentServerUIRoot,
type AgentServerUIRootProps,
} from "./agent-server-ui-root";
export { PostHogWrapper } from "./posthog-wrapper";
@@ -1,84 +0,0 @@
import React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { PostHogProvider } from "posthog-js/react";
import OptionService from "#/api/option-service/option-service.api";
import { QUERY_KEYS, CONFIG_CACHE_OPTIONS } from "#/hooks/query/query-keys";
const POSTHOG_BOOTSTRAP_KEY = "posthog_bootstrap";
function getBootstrapIds() {
// Try to extract from URL hash (e.g. #distinct_id=abc&session_id=xyz)
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const distinctId = params.get("distinct_id");
const sessionId = params.get("session_id");
if (distinctId && sessionId) {
const bootstrap = { distinctID: distinctId, sessionID: sessionId };
// Persist to sessionStorage so IDs survive full-page OAuth redirects
sessionStorage.setItem(POSTHOG_BOOTSTRAP_KEY, JSON.stringify(bootstrap));
// Clean the hash from the URL
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
return bootstrap;
}
// Fallback: check sessionStorage (covers return from OAuth redirect)
const stored = sessionStorage.getItem(POSTHOG_BOOTSTRAP_KEY);
if (stored) {
sessionStorage.removeItem(POSTHOG_BOOTSTRAP_KEY);
return JSON.parse(stored) as { distinctID: string; sessionID: string };
}
return undefined;
}
export function PostHogWrapper({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
null,
);
const [isLoading, setIsLoading] = React.useState(true);
const bootstrapIds = React.useMemo(() => getBootstrapIds(), []);
React.useEffect(() => {
(async () => {
try {
// Use fetchQuery for automatic caching and deduplication
const config = await queryClient.fetchQuery({
queryKey: QUERY_KEYS.WEB_CLIENT_CONFIG,
queryFn: OptionService.getConfig,
meta: { disableToast: true },
...CONFIG_CACHE_OPTIONS,
});
setPosthogClientKey(config.posthog_client_key);
} catch {
// Analytics are optional; keep onboarding and recovery flows quiet.
} finally {
setIsLoading(false);
}
})();
}, [queryClient]);
if (isLoading || !posthogClientKey) {
return children;
}
return (
<PostHogProvider
apiKey={posthogClientKey}
options={{
api_host: "https://us.i.posthog.com",
person_profiles: "identified_only",
bootstrap: bootstrapIds,
}}
>
{children}
</PostHogProvider>
);
}
@@ -0,0 +1,95 @@
import React from "react";
import type { BootstrapConfig } from "posthog-js";
import {
configurePostHogBootstrap,
configureTelemetry,
initializePostHogClient,
type TelemetryConfiguration,
} from "#/services/telemetry";
const POSTHOG_BOOTSTRAP_KEY = "posthog_bootstrap";
function isBootstrapConfig(value: unknown): value is BootstrapConfig {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.distinctID === "string" &&
typeof candidate.sessionID === "string"
);
}
function readBootstrapIds(): BootstrapConfig | undefined {
if (typeof window === "undefined" || typeof sessionStorage === "undefined") {
return undefined;
}
const params = new URLSearchParams(window.location.hash.slice(1));
const distinctID = params.get("distinct_id");
const sessionID = params.get("session_id");
if (distinctID && sessionID) {
const bootstrap = { distinctID, sessionID };
try {
sessionStorage.setItem(POSTHOG_BOOTSTRAP_KEY, JSON.stringify(bootstrap));
} catch {
// OAuth continuity is best effort when browser storage is unavailable.
}
try {
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
} catch {
// Telemetry must never prevent the application from rendering.
}
return bootstrap;
}
try {
const stored = sessionStorage.getItem(POSTHOG_BOOTSTRAP_KEY);
if (!stored) return undefined;
sessionStorage.removeItem(POSTHOG_BOOTSTRAP_KEY);
const parsed: unknown = JSON.parse(stored);
return isBootstrapConfig(parsed) ? parsed : undefined;
} catch {
try {
sessionStorage.removeItem(POSTHOG_BOOTSTRAP_KEY);
} catch {
// Ignore unavailable storage.
}
return undefined;
}
}
export function TelemetryProvider({
children,
config = {},
}: {
children: React.ReactNode;
config?: TelemetryConfiguration;
}) {
const configuredBootstrap = React.useRef(false);
const analyticsEnabled = config !== false;
const apiKey = config === false ? undefined : config.apiKey;
const apiHost = config === false ? undefined : config.apiHost;
const uiHost = config === false ? undefined : config.uiHost;
React.useLayoutEffect(() => {
configureTelemetry(analyticsEnabled ? { apiKey, apiHost, uiHost } : false);
if (!configuredBootstrap.current) {
configurePostHogBootstrap(readBootstrapIds());
configuredBootstrap.current = true;
}
}, [analyticsEnabled, apiHost, apiKey, uiHost]);
React.useEffect(() => {
if (analyticsEnabled) {
void initializePostHogClient().catch(() => {
// Analytics are optional; the service retries on the next operation.
});
}
}, [analyticsEnabled, apiHost, apiKey, uiHost]);
return children;
}
@@ -11,7 +11,6 @@ import React, {
import { ConversationClient } from "@openhands/typescript-client/clients";
import { useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
import { SERVER_CONNECTION_ERROR_MESSAGE } from "#/constants/server-connection-error";
import { useEventStore } from "#/stores/use-event-store";
@@ -138,7 +137,6 @@ export function ConversationWebSocketProvider({
const hasConnectedRefMain = React.useRef(false);
const hasConnectedRefPlanning = React.useRef(false);
const posthog = usePostHog();
const queryClient = useQueryClient();
const addEvent = useEventStore((state) => state.addEvent);
const addEvents = useEventStore((state) => state.addEvents);
@@ -501,7 +499,6 @@ export function ConversationWebSocketProvider({
eventId: errorEvent.id,
errorCode: errorEvent.code,
},
posthog,
});
setErrorMessage(errorEvent.detail, "conversation", errorEvent.code);
} else {
@@ -519,7 +516,6 @@ export function ConversationWebSocketProvider({
toolName: event.tool_name,
toolCallId: event.tool_call_id,
},
posthog,
});
}
@@ -659,7 +655,6 @@ export function ConversationWebSocketProvider({
appendOutput,
updateMetricsFromStats,
handleNonErrorEvent,
posthog,
],
);
@@ -703,7 +698,6 @@ export function ConversationWebSocketProvider({
eventId: errorEvent.id,
errorCode: errorEvent.code,
},
posthog,
});
setErrorMessage(errorEvent.detail, "conversation", errorEvent.code);
} else {
@@ -721,7 +715,6 @@ export function ConversationWebSocketProvider({
toolName: event.tool_name,
toolCallId: event.tool_call_id,
},
posthog,
});
}
@@ -842,7 +835,6 @@ export function ConversationWebSocketProvider({
setPlanContent,
updateMetricsFromStats,
handleNonErrorEvent,
posthog,
],
);
+5 -1
View File
@@ -59,12 +59,16 @@ const saveSettingsMutationFn = async (settings: SettingsUpdate) => {
await SettingsService.saveSettings(settingsToSave);
};
export const useSaveSettings = (scope: SettingsScope = "personal") => {
export const useSaveSettings = (
scope: SettingsScope = "personal",
{ retry }: { retry?: number } = {},
) => {
const { trackMcpConfigUpdated } = useTracking();
const queryClient = useQueryClient();
const { data: currentSettings } = useSettings(scope);
return useMutation({
...(retry === undefined ? {} : { retry }),
mutationFn: async (settings: SettingsUpdate) => {
const nextMcpConfig = settings.mcp_config as MCPConfig | undefined;
const currentMcpConfig = currentSettings?.mcp_config as
-36
View File
@@ -1,36 +0,0 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSaveSettings } from "./mutation/use-save-settings";
export const useMigrateUserConsent = () => {
const posthog = usePostHog();
const { mutate: saveUserSettings } = useSaveSettings();
/**
* Migrate user consent to the settings store on the server.
*/
const migrateUserConsent = React.useCallback(
async (args?: { handleAnalyticsWasPresentInLocalStorage: () => void }) => {
const userAnalyticsConsent = localStorage.getItem("analytics-consent");
if (userAnalyticsConsent) {
args?.handleAnalyticsWasPresentInLocalStorage();
saveUserSettings(
{ user_consents_to_analytics: userAnalyticsConsent === "true" },
{
onSuccess: () => {
handleCaptureConsent(posthog, userAnalyticsConsent === "true");
},
},
);
localStorage.removeItem("analytics-consent");
}
},
[posthog, saveUserSettings],
);
return { migrateUserConsent };
};
-47
View File
@@ -1,47 +0,0 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
import { useSettings } from "#/hooks/query/use-settings";
/**
* Calls posthog.identify() for cloud users who have granted analytics consent.
*
* Cloud mode only — local mode has no stable server-issued user ID and
* person_profiles="identified_only" would silently drop all events anyway.
*
* Identity lifecycle:
* - consent === true + userId present → posthog.identify(userId, { email })
* - consent === false (explicit denial) → posthog.reset()
* - userId becomes null after identify → posthog.reset() (logout)
* - consent === null / settings loading → no-op (wait for decision)
*/
export const usePostHogIdentify = () => {
const posthog = usePostHog();
const { backend } = useActiveBackend();
const { data: settings } = useSettings();
const userIds = useCloudCurrentUserId();
const hasIdentifiedRef = React.useRef(false);
const isCloud = backend.kind === "cloud";
const userId = isCloud ? (userIds[backend.id]?.userId ?? null) : null;
const consent = settings?.user_consents_to_analytics;
React.useEffect(() => {
if (!posthog || !isCloud || settings === undefined) return;
if (consent === true && userId) {
posthog.identify(userId, {
email: settings.email ?? settings.git_user_email ?? undefined,
});
hasIdentifiedRef.current = true;
return;
}
// Reset on explicit denial or on logout (userId gone after a prior identify)
if (consent === false || (hasIdentifiedRef.current && !userId)) {
posthog.reset();
hasIdentifiedRef.current = false;
}
}, [posthog, isCloud, consent, userId, settings?.email, settings]);
};
-29
View File
@@ -1,29 +0,0 @@
import React from "react";
import { usePostHog } from "posthog-js/react";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSettings } from "./query/use-settings";
/**
* Hook to sync PostHog opt-in/out state with the backend setting.
*
* Runs whenever settings change so that a consent decision made in one tab
* or via the API is picked up without a page reload.
*
* Consent model:
* true → opt in (user explicitly accepted)
* false → opt out (user explicitly denied)
* null → opt out (consent not yet collected — safe default while loading
* or on first visit, prevents capturing before the user has decided)
*/
export const useSyncPostHogConsent = () => {
const posthog = usePostHog();
const { data: settings } = useSettings();
React.useEffect(() => {
if (!posthog || settings === undefined) return;
// null and false are both treated as "not consented".
// Only an explicit true opts PostHog in.
handleCaptureConsent(posthog, settings.user_consents_to_analytics === true);
}, [posthog, settings]);
};
+86
View File
@@ -0,0 +1,86 @@
import React from "react";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import {
clearPendingCloudTelemetryConsent,
getPendingCloudTelemetryConsent,
setTelemetryConsent,
subscribeTelemetryConsent,
} from "#/services/telemetry";
import { useSettings } from "./query/use-settings";
/**
* Hook to sync PostHog opt-in/out state with the backend setting.
*
* Reconciles the browser's capture state with the backend preference.
*
* A first-run user can make an explicit choice before Cloud login. That newer
* browser decision remains pending across local backends and is written to the
* Cloud backend after login; until Cloud confirms it, a stale/default backend
* value must not overwrite it. With no pending browser choice, backend updates
* remain authoritative so revocation in another tab or via the API is respected.
*
* Consent model:
* true → opt in (user explicitly accepted)
* false → opt out (user explicitly denied)
* null → opt out (consent not yet collected — safe default while loading
* or on first visit, prevents capturing before the user has decided)
*/
export const useSyncTelemetryConsent = () => {
const { backend } = useActiveBackend();
const { data: settings } = useSettings();
const { mutate: saveSettings, isPending: isSavingSettings } = useSaveSettings(
"personal",
{ retry: 2 },
);
const pendingBrowserConsent = React.useSyncExternalStore(
subscribeTelemetryConsent,
getPendingCloudTelemetryConsent,
() => null,
);
const attemptedSyncRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (settings === undefined) return;
if (pendingBrowserConsent !== null) {
const backendConsent =
settings.user_consents_to_analytics === true
? "granted"
: settings.user_consents_to_analytics === false
? "denied"
: null;
if (backendConsent === pendingBrowserConsent) {
attemptedSyncRef.current = null;
if (backend.kind === "cloud") {
clearPendingCloudTelemetryConsent(pendingBrowserConsent);
}
return;
}
const syncKey = `${backend.id}:${pendingBrowserConsent}`;
if (isSavingSettings || attemptedSyncRef.current === syncKey) return;
attemptedSyncRef.current = syncKey;
saveSettings({
user_consents_to_analytics: pendingBrowserConsent === "granted",
});
return;
}
// null and false are both treated as "not consented".
// Only an explicit true opts PostHog in.
void setTelemetryConsent(
settings.user_consents_to_analytics === true ? "granted" : "denied",
{ syncToCloud: false },
);
}, [
backend.id,
backend.kind,
isSavingSettings,
pendingBrowserConsent,
saveSettings,
settings,
]);
};
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
import { useSettings } from "#/hooks/query/use-settings";
import { setTelemetryIdentity } from "#/services/telemetry";
/** Keep the telemetry service aligned with the resolved Cloud account. */
export const useTelemetryIdentity = () => {
const { backend } = useActiveBackend();
const { data: settings } = useSettings();
const userIds = useCloudCurrentUserId();
const identity = backend.kind === "cloud" ? userIds[backend.id] : undefined;
const isIdentityLoading = identity?.isLoading ?? true;
const userId = identity?.userId ?? null;
const email = settings?.email || settings?.git_user_email || undefined;
React.useEffect(() => {
// A local backend says nothing about Cloud login state. Preserve the last
// Cloud identity until a Cloud query explicitly resolves it.
if (backend.kind !== "cloud" || isIdentityLoading) return;
if (!userId) {
void setTelemetryIdentity(null);
return;
}
void setTelemetryIdentity(userId, email ? { email } : {});
}, [backend.kind, email, isIdentityLoading, userId]);
};
+16 -26
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import {
getTelemetryConsent,
setTelemetryConsent,
@@ -6,6 +6,7 @@ import {
trackSessionStart,
trackEvent,
clearTelemetryData,
subscribeTelemetryConsent,
type TelemetryConsent,
} from "#/services/telemetry";
@@ -17,13 +18,13 @@ export interface UseTelemetryReturn {
/** Whether consent prompt should be shown */
showConsentPrompt: boolean;
/** Grant consent and enable telemetry */
grantConsent: () => void;
grantConsent: () => Promise<void>;
/** Deny consent and disable telemetry */
denyConsent: () => void;
denyConsent: () => Promise<void>;
/** Track a custom event (only if consent granted) */
track: (eventName: string, properties?: Record<string, unknown>) => void;
/** Clear all telemetry data */
clearData: () => void;
clearData: () => Promise<void>;
}
/**
@@ -58,8 +59,10 @@ export interface UseTelemetryReturn {
* ```
*/
export function useTelemetry(): UseTelemetryReturn {
const [consent, setConsentState] = useState<TelemetryConsent>(() =>
getTelemetryConsent(),
const consent = useSyncExternalStore<TelemetryConsent>(
subscribeTelemetryConsent,
getTelemetryConsent,
() => "pending",
);
// Track install immediately on first mount (regardless of consent)
@@ -75,42 +78,29 @@ export function useTelemetry(): UseTelemetryReturn {
useEffect(() => {
if (!hasTrackedInstall.current) {
hasTrackedInstall.current = true;
trackInstall();
void trackInstall();
}
}, []);
// Track session start when consent is granted
useEffect(() => {
if (consent === "granted") {
trackSessionStart();
void trackSessionStart();
}
}, [consent]);
const grantConsent = useCallback(async () => {
// Must await to ensure PostHog is initialized and opt_in_capturing() is called
// before the useEffect triggers tracking calls
await setTelemetryConsent("granted");
setConsentState("granted");
}, []);
const grantConsent = useCallback(() => setTelemetryConsent("granted"), []);
const denyConsent = useCallback(async () => {
await setTelemetryConsent("denied");
setConsentState("denied");
}, []);
const denyConsent = useCallback(() => setTelemetryConsent("denied"), []);
const track = useCallback(
(eventName: string, properties?: Record<string, unknown>) => {
if (consent === "granted") {
trackEvent(eventName, properties);
}
void trackEvent(eventName, properties);
},
[consent],
[],
);
const clearData = useCallback(() => {
clearTelemetryData();
setConsentState("pending");
}, []);
const clearData = useCallback(() => clearTelemetryData(), []);
return {
consent,
+8 -10
View File
@@ -1,21 +1,19 @@
import { usePostHog } from "posthog-js/react";
import { useSettings } from "./query/use-settings";
import { Provider } from "#/types/settings";
import type { BackendKind } from "#/api/backend-registry/types";
import type { WorkspaceMode } from "#/api/conversation-metadata-store";
import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics";
import { trackEvent } from "#/services/telemetry";
/**
* Hook that provides tracking functions with automatic data collection
* from available hooks (settings, etc.)
*
* All events require explicit user consent (user_consents_to_analytics === true).
* Events are silently dropped when:
* - posthog is not initialized (VITE_POSTHOG_CLIENT_KEY not set)
* - user_consents_to_analytics is false or null (consent not yet collected)
* All events require explicit user consent. The shared PostHog client enforces
* the canonical consent configured by telemetry.ts; this hook must not gate on
* backend settings because they can be stale while a backend changes.
*/
export const useTracking = () => {
const posthog = usePostHog();
const { data: settings } = useSettings();
// Common properties included in all tracking events
@@ -25,12 +23,12 @@ export const useTracking = () => {
};
/**
* Capture an event only when PostHog is available and the user has
* explicitly consented. null and false are both treated as "not consented".
* PostHog enforces the canonical consent state configured by telemetry.ts.
* Backend settings are not a capture gate because they can be stale while a
* backend is being added or switched.
*/
const track = (event: string, properties: Record<string, unknown> = {}) => {
if (!posthog || settings?.user_consents_to_analytics !== true) return;
posthog.capture(event, { ...properties, ...commonProperties });
void trackEvent(event, { ...properties, ...commonProperties });
};
const trackLoginButtonClick = ({ provider }: { provider: Provider }) => {
+4 -1
View File
@@ -9,6 +9,7 @@ export {
AgentServerUIRoot,
DEFAULT_AGENT_SERVER_ANALYTICS,
type AgentServerUIAnalyticsConfig,
type AgentServerUIPostHogAnalyticsConfig,
type AgentServerUIProvidersProps,
type AgentServerUIRootProps,
} from "../components/providers";
@@ -43,6 +44,7 @@ export {
export { TelemetryConsentBanner } from "../components/features/analytics/telemetry-consent-banner";
export { useTelemetry, type UseTelemetryReturn } from "../hooks/use-telemetry";
export {
configureTelemetry,
getTelemetryConsent,
setTelemetryConsent,
isTelemetryEnabled,
@@ -50,6 +52,7 @@ export {
trackSessionStart,
trackEvent,
clearTelemetryData,
getPostHogInstance,
type TelemetryConfig,
type TelemetryConfiguration,
type TelemetryConsent,
} from "../services/telemetry";
-3
View File
@@ -3,7 +3,4 @@
interface Window {
__GITHUB_CLIENT_ID__?: string | null;
posthog?: {
capture: (event: string, properties?: Record<string, unknown>) => void;
};
}
-2
View File
@@ -50,7 +50,6 @@ const DEFAULT_MODEL =
export const createMockWebClientConfig = (
overrides: Partial<WebClientConfig> = {},
): WebClientConfig => ({
posthog_client_key: "test-posthog-key",
feature_flags: {
hide_llm_settings: false,
hide_users_page: false,
@@ -905,7 +904,6 @@ export const SETTINGS_HANDLERS = [
http.get("*/api/v1/web-client/config", () => {
const config: WebClientConfig = {
posthog_client_key: "fake-posthog-client-key",
feature_flags: {
hide_llm_settings: false,
hide_users_page: false,
-6
View File
@@ -4,9 +4,3 @@
// @openhands/extensions skills directory in node_modules, or an
// empty string in library builds.
declare const __EXTENSIONS_SKILLS_DIR__: string;
interface Window {
posthog?: {
capture: (event: string, properties?: Record<string, unknown>) => void;
};
}
-4
View File
@@ -1,6 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { usePostHog } from "posthog-js/react";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSettings } from "#/hooks/query/use-settings";
import { AvailableLanguages } from "#/i18n";
@@ -12,7 +11,6 @@ import { SettingsInput } from "#/components/features/settings/settings-input";
import { I18nKey } from "#/i18n/declaration";
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
import { ThemeInput } from "#/components/features/settings/app-settings/theme-input";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import {
displayErrorToast,
displaySuccessToast,
@@ -21,7 +19,6 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
export function AppSettingsScreen() {
const posthog = usePostHog();
const { t } = useTranslation("openhands");
const { mutate: saveSettings, isPending } = useSaveSettings();
@@ -69,7 +66,6 @@ export function AppSettingsScreen() {
},
{
onSuccess: () => {
handleCaptureConsent(posthog, enableAnalytics);
void setTelemetryConsent(enableAnalytics ? "granted" : "denied");
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
},
+4 -10
View File
@@ -13,10 +13,9 @@ import { Sidebar } from "#/components/features/sidebar/sidebar";
import { SidebarMobileNavProvider } from "#/components/features/sidebar/sidebar-mobile-nav-context";
import { SidebarMobileMenuBar } from "#/components/features/sidebar/sidebar-mobile-menu-bar";
import { useSettings } from "#/hooks/query/use-settings";
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
import { useEnsureActiveProfile } from "#/hooks/use-ensure-active-profile";
import { useSyncPostHogConsent } from "#/hooks/use-sync-posthog-consent";
import { usePostHogIdentify } from "#/hooks/use-posthog-identify";
import { useSyncTelemetryConsent } from "#/hooks/use-sync-telemetry-consent";
import { useTelemetryIdentity } from "#/hooks/use-telemetry-identity";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useAppTitle } from "#/hooks/use-app-title";
import { ReactRouterNavigationProvider } from "./react-router-navigation-provider";
@@ -74,11 +73,10 @@ export default function MainApp() {
const location = useLocation();
const appTitle = useAppTitle();
const { data: settings } = useSettings();
const { migrateUserConsent } = useMigrateUserConsent();
const config = useConfig();
useSyncPostHogConsent();
usePostHogIdentify();
useSyncTelemetryConsent();
useTelemetryIdentity();
// Local-mode policy: keep a profile active so a usable LLM is always selected.
useEnsureActiveProfile();
@@ -88,10 +86,6 @@ export default function MainApp() {
}
}, [settings?.language]);
React.useEffect(() => {
migrateUserConsent();
}, [migrateUserConsent]);
if (config.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-base">
-1
View File
@@ -72,7 +72,6 @@ export function handleStatusMessage(message: StatusMessage) {
message: message.message,
source: "chat",
metadata: { msgId: message.id },
posthog: undefined, // Service file - can't use hooks
});
}
}
+12 -42
View File
@@ -1,28 +1,19 @@
import type { BackendKind } from "#/api/backend-registry/types";
import {
AGENT_CANVAS_CLIENT_SOURCE,
AGENT_CANVAS_CLIENT_VERSION,
} from "#/api/client-source";
import { AGENT_CANVAS_CLIENT_SOURCE } from "#/api/client-source";
import { isOpenHandsCloudHost } from "#/api/device-flow-client";
import { isTelemetryEnabled, trackEvent } from "#/services/telemetry";
import { trackEvent } from "#/services/telemetry";
export type CloudConnectionSource =
| "onboarding"
| "add_backend_modal"
| "manage_backends_modal";
const commonProperties = {
client_source: AGENT_CANVAS_CLIENT_SOURCE,
client_version: AGENT_CANVAS_CLIENT_VERSION,
};
const CLOUD_CONVERSATION_READY_INSERT_ID_PREFIX = `${AGENT_CANVAS_CLIENT_SOURCE}:cloud_conversation_ready`;
function trackCloudFunnelEvent(
event: string,
properties: Record<string, unknown>,
): boolean {
if (!isTelemetryEnabled()) return false;
void trackEvent(event, { ...properties, ...commonProperties });
return true;
): void {
void trackEvent(event, properties);
}
function hostClassification(host: string) {
@@ -36,8 +27,8 @@ function hostClassification(host: string) {
export function trackCloudDeviceAuthorizationStarted(
host: string,
source?: CloudConnectionSource,
): boolean {
return trackCloudFunnelEvent("cloud_device_authorization_started", {
): void {
trackCloudFunnelEvent("cloud_device_authorization_started", {
...hostClassification(host),
source,
});
@@ -46,40 +37,19 @@ export function trackCloudDeviceAuthorizationStarted(
export function trackCloudDeviceAuthorizationSucceeded(
host: string,
source?: CloudConnectionSource,
): boolean {
return trackCloudFunnelEvent("cloud_device_authorization_succeeded", {
): void {
trackCloudFunnelEvent("cloud_device_authorization_succeeded", {
...hostClassification(host),
source,
});
}
export function trackCanvasBackendAdded({
backendKind,
connectionMethod,
host,
hasApiKey,
source,
}: {
backendKind: BackendKind;
connectionMethod: "manual" | "cloud_login";
host: string;
hasApiKey: boolean;
source?: CloudConnectionSource;
}): boolean {
return trackCloudFunnelEvent("backend_added", {
backend_kind: backendKind,
connection_method: connectionMethod,
...hostClassification(host),
has_api_key: hasApiKey,
source,
});
}
export function trackCloudConversationReady(
taskId: string,
conversationId: string,
): boolean {
return trackCloudFunnelEvent("cloud_conversation_ready", {
): void {
trackCloudFunnelEvent("cloud_conversation_ready", {
$insert_id: `${CLOUD_CONVERSATION_READY_INSERT_ID_PREFIX}:${taskId}`,
task_id: taskId,
conversation_id: conversationId,
});
+420 -98
View File
@@ -18,51 +18,199 @@
* - VITE_POSTHOG_UI_HOST: PostHog UI host (defaults to https://us.posthog.com)
*
* IMPORTANT: By default, telemetry is sent to the OpenHands PostHog project.
* Library consumers can override this by setting VITE_POSTHOG_API_KEY.
* Source builds can override this with VITE_POSTHOG_API_KEY. Precompiled
* library consumers can pass the same settings to configureTelemetry().
*
* Users can disable all telemetry (including install tracking) via:
* - Setting VITE_DO_NOT_TRACK=1 environment variable
* - Browser's Do Not Track setting
*/
import type { PostHog } from "posthog-js";
import type { BootstrapConfig, CaptureResult, PostHog } from "posthog-js";
import packageJson from "../../package.json";
import {
AGENT_CANVAS_CLIENT_SOURCE,
AGENT_CANVAS_CLIENT_VERSION,
} from "#/api/client-source";
const TELEMETRY_CONSENT_KEY = "openhands-telemetry-consent";
const TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY =
"openhands-telemetry-consent-pending-cloud-sync";
const TELEMETRY_CONSENT_CHANGE_EVENT = "openhands-telemetry-consent-change";
const TELEMETRY_FIRST_USE_KEY = "openhands-telemetry-first-use";
const TELEMETRY_SESSION_KEY = "openhands-telemetry-session";
const POSTHOG_INSTANCE_NAME = "agent-canvas";
// PostHog project keys — one per deployment environment, hardcoded so they
// are baked into the static bundle at build time and cannot drift at runtime.
// Replace POSTHOG_STAGING_KEY with a dedicated project key once provisioned.
const POSTHOG_PROD_KEY = "phc_BgzfxKdgsYMLFTmJqt424ZoyVHvKFfrwttLimzdYTKFK";
// Unconfigured source builds use staging. Production release workflows pass
// VITE_POSTHOG_API_KEY explicitly for both the app and library artifacts.
const POSTHOG_STAGING_KEY = "phc_kBtz5nKmxVRRQ7HtPwr2QX9eMC5j65zE86QKocVNwb4U";
// Always use the staging key unless VITE_APP_ENV is explicitly set to
// "production" at bundle time (hardcoded in build:lib and production CI).
// Library consumers can always override with VITE_POSTHOG_API_KEY.
const POSTHOG_API_KEY: string =
const DEFAULT_POSTHOG_API_KEY: string =
(import.meta.env.VITE_POSTHOG_API_KEY as string | undefined) ||
(import.meta.env.VITE_APP_ENV === "production"
? POSTHOG_PROD_KEY
: POSTHOG_STAGING_KEY);
POSTHOG_STAGING_KEY;
// Default to OpenHands' reverse proxy to bypass ad blockers.
// The proxy at z.openhands.dev routes to PostHog's US region.
// Library consumers can override this with their own proxy or direct PostHog URL.
const POSTHOG_HOST =
const DEFAULT_POSTHOG_HOST =
import.meta.env.VITE_POSTHOG_HOST || "https://z.openhands.dev";
// UI host is needed for PostHog features like toolbar to work correctly
// when using a reverse proxy. Defaults to US region.
const POSTHOG_UI_HOST =
const DEFAULT_POSTHOG_UI_HOST =
import.meta.env.VITE_POSTHOG_UI_HOST || "https://us.posthog.com";
export type TelemetryConsent = "granted" | "denied" | "pending";
export interface TelemetryConfig {
/** PostHog project key. Useful for precompiled library consumers. */
apiKey?: string;
/** Event ingestion host or reverse proxy. */
apiHost?: string;
/** PostHog UI host used by toolbar links and other UI features. */
uiHost?: string;
}
export type TelemetryConfiguration = TelemetryConfig | false;
export type TelemetryConsent = "granted" | "denied" | "pending";
export type ResolvedTelemetryConsent = Exclude<TelemetryConsent, "pending">;
export interface SetTelemetryConsentOptions {
/** Do not persist a value mirrored from backend settings back to Cloud. */
syncToCloud?: boolean;
}
let isInitialized = false;
let posthogInstance: PostHog | null = null;
let initializationPromise: Promise<PostHog | null> | null = null;
let pendingBootstrap: BootstrapConfig | undefined;
let telemetryConfig: TelemetryConfig = {};
let telemetryDisabled = false;
interface TelemetryIdentity {
distinctId: string;
properties: Record<string, unknown>;
}
// undefined means that Cloud identity has not resolved yet; null means that it
// resolved without a user. This distinction prevents startup from resetting a
// persisted identity while the current account is still loading.
let desiredTelemetryIdentity: TelemetryIdentity | null | undefined;
let desiredIdentityRevision = 0;
let appliedIdentityRevision = -1;
const CANVAS_EVENT_PROPERTIES = Object.freeze({
client_source: AGENT_CANVAS_CLIENT_SOURCE,
client_version: AGENT_CANVAS_CLIENT_VERSION,
package_name: packageJson.name,
package_version: packageJson.version,
});
function addCanvasEventProperties(
event: CaptureResult | null,
): CaptureResult | null {
if (!event) return null;
return {
...event,
properties: {
...event.properties,
...CANVAS_EVENT_PROPERTIES,
},
};
}
function restorePostHogConsent(posthog: PostHog): void {
if (telemetryDisabled || getTelemetryConsent() !== "granted") {
posthog.opt_out_capturing();
} else {
posthog.opt_in_capturing();
}
}
function resetPostHogIdentity(posthog: PostHog, resetDeviceId = false): void {
posthog.reset(resetDeviceId);
appliedIdentityRevision = -1;
// PostHog reset clears its own consent persistence, so immediately restore
// the canonical Canvas decision kept in localStorage.
restorePostHogConsent(posthog);
}
function applyDesiredTelemetryIdentity(posthog: PostHog): void {
if (desiredTelemetryIdentity === undefined || !isTelemetryEnabled()) return;
const desiredId = desiredTelemetryIdentity?.distinctId;
const currentId = posthog.get_property("$user_id");
if (currentId != null && currentId !== desiredId) {
resetPostHogIdentity(posthog);
}
if (desiredTelemetryIdentity === null) {
appliedIdentityRevision = desiredIdentityRevision;
return;
}
if (
posthog.get_property("$user_id") !== desiredTelemetryIdentity.distinctId ||
appliedIdentityRevision !== desiredIdentityRevision
) {
posthog.identify(
desiredTelemetryIdentity.distinctId,
desiredTelemetryIdentity.properties,
);
appliedIdentityRevision = desiredIdentityRevision;
}
}
function propertiesEqual(
left: Record<string, unknown>,
right: Record<string, unknown>,
): boolean {
const keys = Object.keys(left);
return (
keys.length === Object.keys(right).length &&
keys.every((key) => left[key] === right[key])
);
}
/**
* Configure the single Canvas telemetry client before its first use.
* Passing false disables telemetry and install tracking for embedded hosts.
*/
export function configureTelemetry(config: TelemetryConfiguration): void {
if (config === false) {
if (telemetryDisabled) return;
telemetryDisabled = true;
posthogInstance?.opt_out_capturing();
notifyTelemetryConsentListeners();
return;
}
const wasDisabled = telemetryDisabled;
telemetryDisabled = false;
if (!posthogInstance && !initializationPromise) {
const definedConfig = Object.fromEntries(
Object.entries(config).filter(([, value]) => value !== undefined),
) as TelemetryConfig;
telemetryConfig = { ...telemetryConfig, ...definedConfig };
}
if (wasDisabled) {
if (posthogInstance) {
restorePostHogConsent(posthogInstance);
applyDesiredTelemetryIdentity(posthogInstance);
}
notifyTelemetryConsentListeners();
}
}
function getResolvedTelemetryConfig(): Required<TelemetryConfig> | null {
if (telemetryDisabled) return null;
return {
apiKey: telemetryConfig.apiKey || DEFAULT_POSTHOG_API_KEY,
apiHost: telemetryConfig.apiHost || DEFAULT_POSTHOG_HOST,
uiHost: telemetryConfig.uiHost || DEFAULT_POSTHOG_UI_HOST,
};
}
/**
* Check if we're in a browser environment
@@ -98,6 +246,10 @@ async function getPostHog(): Promise<PostHog | null> {
* Works in both Node.js and browser (Vite) environments.
*/
function isDoNotTrackEnabled(): boolean {
if (telemetryDisabled) {
return true;
}
// Check Vite environment variable (browser)
if (
typeof import.meta !== "undefined" &&
@@ -130,52 +282,80 @@ function isDoNotTrackEnabled(): boolean {
* @param enableCapturing - If true, enable capturing immediately (for install tracking).
* If false, start with capturing disabled (for consent-gated tracking).
*/
async function initializePostHog(
export function configurePostHogBootstrap(
bootstrap: BootstrapConfig | undefined,
): void {
if (!posthogInstance) {
pendingBootstrap = bootstrap;
}
}
export async function initializePostHogClient(
enableCapturing = false,
): Promise<PostHog | null> {
if (isInitialized) {
if (posthogInstance) {
return posthogInstance;
}
const posthog = await getPostHog();
if (!posthog) {
return null;
if (initializationPromise) {
return initializationPromise;
}
// Canvas telemetry must not share PostHog's default singleton with host-app
// analytics. A named instance gives it independent configuration, consent,
// persistence, and delivery state.
posthogInstance = posthog.init(
POSTHOG_API_KEY,
{
api_host: POSTHOG_HOST,
// UI host is required when using a reverse proxy so PostHog features work correctly
ui_host: POSTHOG_UI_HOST,
// Start with capturing disabled by default - we enable it explicitly when needed
opt_out_capturing_by_default: !enableCapturing,
// Don't auto-capture page views - we control when to track
capture_pageview: false,
// Don't auto-capture clicks etc.
autocapture: false,
// Use localStorage for persistence
persistence: "localStorage",
persistence_name: POSTHOG_INSTANCE_NAME,
consent_persistence_name: `${POSTHOG_INSTANCE_NAME}-consent`,
// Disable session recording
disable_session_recording: true,
// Set default properties for all events
loaded: (ph) => {
ph.register({
package_name: packageJson.name,
package_version: packageJson.version,
});
},
},
POSTHOG_INSTANCE_NAME,
);
initializationPromise = (async () => {
const config = getResolvedTelemetryConfig();
if (!config) {
return null;
}
isInitialized = true;
return posthogInstance;
const posthog = await getPostHog();
if (!posthog) {
return null;
}
// A named instance isolates Canvas configuration, consent, identity, and
// persistence from a host application's default PostHog singleton.
const initializedPostHog = posthog.init(
config.apiKey,
{
api_host: config.apiHost,
ui_host: config.uiHost,
opt_out_capturing_by_default: !enableCapturing,
capture_pageview: false,
autocapture: false,
persistence: "localStorage",
persistence_name: POSTHOG_INSTANCE_NAME,
consent_persistence_name: `${POSTHOG_INSTANCE_NAME}-consent`,
person_profiles: "identified_only",
disable_session_recording: true,
bootstrap: pendingBootstrap,
before_send: addCanvasEventProperties,
},
POSTHOG_INSTANCE_NAME,
);
if (!initializedPostHog) return null;
posthogInstance = initializedPostHog;
pendingBootstrap = undefined;
if (telemetryDisabled) {
posthogInstance.opt_out_capturing();
} else if (getTelemetryConsent() === "granted") {
posthogInstance.opt_in_capturing();
applyDesiredTelemetryIdentity(posthogInstance);
} else if (!enableCapturing) {
posthogInstance.opt_out_capturing();
}
return posthogInstance;
})();
try {
return await initializationPromise;
} finally {
if (!posthogInstance) {
initializationPromise = null;
}
}
}
/**
@@ -203,11 +383,86 @@ export function getTelemetryConsent(): TelemetryConsent {
return "pending";
}
/**
* Return an explicit browser choice that still needs to survive a Cloud login.
* It remains pending across local backends so their settings cannot consume a
* decision that must still be applied after the user connects to Cloud.
*/
export function getPendingCloudTelemetryConsent(): ResolvedTelemetryConsent | null {
if (!isBrowser()) return null;
try {
const consent = localStorage.getItem(
TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY,
);
return consent === "granted" || consent === "denied" ? consent : null;
} catch {
return null;
}
}
export function subscribeTelemetryConsent(listener: () => void): () => void {
if (!isBrowser()) return () => {};
const handleStorage = (event: StorageEvent) => {
if (
event.key === TELEMETRY_CONSENT_KEY ||
event.key === TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY
) {
listener();
}
};
window.addEventListener(TELEMETRY_CONSENT_CHANGE_EVENT, listener);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener(TELEMETRY_CONSENT_CHANGE_EVENT, listener);
window.removeEventListener("storage", handleStorage);
};
}
function notifyTelemetryConsentListeners(): void {
if (isBrowser())
window.dispatchEvent(new Event(TELEMETRY_CONSENT_CHANGE_EVENT));
}
function markTelemetryConsentForCloudSync(
consent: ResolvedTelemetryConsent,
): void {
if (!isBrowser()) return;
try {
localStorage.setItem(TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY, consent);
} catch {
// Ignore storage errors; the in-browser consent decision still applies.
}
}
export function clearPendingCloudTelemetryConsent(
expected?: ResolvedTelemetryConsent,
): void {
if (!isBrowser()) return;
try {
if (
expected !== undefined &&
localStorage.getItem(TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY) !==
expected
) {
return;
}
localStorage.removeItem(TELEMETRY_CONSENT_PENDING_CLOUD_SYNC_KEY);
notifyTelemetryConsentListeners();
} catch {
// Ignore storage errors.
}
}
/**
* Set user's telemetry consent preference
*/
export async function setTelemetryConsent(
consent: "granted" | "denied",
consent: ResolvedTelemetryConsent,
{ syncToCloud = true }: SetTelemetryConsentOptions = {},
): Promise<void> {
if (!isBrowser()) {
return;
@@ -215,22 +470,66 @@ export async function setTelemetryConsent(
try {
localStorage.setItem(TELEMETRY_CONSENT_KEY, consent);
if (telemetryDisabled) return;
// Initialize PostHog if not already done
const posthog = await initializePostHog();
// Reuse an initialized client synchronously so a same-flush identify()
// cannot run before consent is applied. Only the cold path awaits import.
const posthog = posthogInstance ?? (await initializePostHogClient());
if (!posthog) {
return;
}
if (consent === "granted") {
// Enable capturing
posthog.opt_in_capturing();
applyDesiredTelemetryIdentity(posthog);
} else {
// Disable capturing and clear any queued events
posthog.opt_out_capturing();
if (posthog.get_property("$user_id") != null) {
resetPostHogIdentity(posthog);
} else {
posthog.opt_out_capturing();
}
}
} catch {
// Ignore storage errors
} finally {
// Notify UI/backend/identity reconcilers only after the browser capture state
// reflects this decision. Otherwise a pre-login grant can trigger an
// identify while PostHog is still opted out and lose funnel continuity.
if (syncToCloud) {
markTelemetryConsentForCloudSync(consent);
}
notifyTelemetryConsentListeners();
}
}
/**
* Declare the current Cloud identity. The telemetry service applies it only
* after consent and owns all reset/account-switch semantics.
*/
export async function setTelemetryIdentity(
distinctId: string | null,
properties: Record<string, unknown> = {},
): Promise<void> {
const nextIdentity = distinctId === null ? null : { distinctId, properties };
const unchanged =
desiredTelemetryIdentity === nextIdentity ||
(desiredTelemetryIdentity !== undefined &&
desiredTelemetryIdentity !== null &&
nextIdentity !== null &&
desiredTelemetryIdentity.distinctId === nextIdentity.distinctId &&
propertiesEqual(desiredTelemetryIdentity.properties, properties));
if (unchanged) return;
desiredTelemetryIdentity = nextIdentity;
desiredIdentityRevision += 1;
appliedIdentityRevision = -1;
if (!isTelemetryEnabled()) return;
const posthog = posthogInstance ?? (await initializePostHogClient());
if (posthog && isTelemetryEnabled()) {
// Read the desired identity after the await so a newer account always wins
// if identity changes while the SDK is loading.
applyDesiredTelemetryIdentity(posthog);
}
}
@@ -298,8 +597,8 @@ export async function trackInstall(): Promise<void> {
}
// Initialize PostHog with capturing enabled (for this one event)
const posthog = await initializePostHog(true);
if (!posthog) {
const posthog = await initializePostHogClient(true);
if (!posthog || isDoNotTrackEnabled()) {
return;
}
@@ -360,26 +659,40 @@ function markSessionSent(): void {
}
}
/** Return the shared client only when a consented capture is safe to emit. */
async function getPostHogForConsentedCapture(): Promise<PostHog | null> {
if (!isTelemetryEnabled()) return null;
const posthog = await initializePostHogClient();
if (!posthog || !isTelemetryEnabled()) return null;
// The browser preference is the canonical capture decision. PostHog may
// still carry an older opt-out marker while backend consent is loading or
// after a previous backend temporarily reported a stale value. Heal that
// drift at the event boundary so capture() cannot silently discard an event
// that the user has explicitly allowed.
if (posthog.has_opted_out_capturing?.()) {
posthog.opt_in_capturing();
}
applyDesiredTelemetryIdentity(posthog);
return posthog;
}
/**
* Track a session start event.
* Called each time a new browser session starts (respects consent).
* Uses sessionStorage for deduplication - only sends once per browser session.
*/
export async function trackSessionStart(): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
// Already sent session event this browser session
if (hasSessionSent()) {
return;
}
// Initialize PostHog if needed
const posthog = await initializePostHog();
if (!posthog) {
return;
}
const posthog = await getPostHogForConsentedCapture();
if (!posthog) return;
posthog.capture("canvas_new_session", {
is_first_use: !hasFirstUseSent(),
@@ -396,19 +709,23 @@ export async function trackEvent(
eventName: string,
properties: Record<string, unknown> = {},
): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
// Initialize PostHog if needed
const posthog = await initializePostHog();
if (!posthog) {
return;
}
const posthog = await getPostHogForConsentedCapture();
if (!posthog) return;
posthog.capture(eventName, properties);
}
/** Track an exception through the same consent-aware client as custom events. */
export async function trackException(
error: unknown,
properties: Record<string, unknown> = {},
): Promise<void> {
const posthog = await getPostHogForConsentedCapture();
if (!posthog) return;
posthog.captureException(error, properties);
}
/**
* Clear all telemetry data (for privacy/GDPR requests)
*/
@@ -420,25 +737,30 @@ export async function clearTelemetryData(): Promise<void> {
try {
localStorage.removeItem(TELEMETRY_CONSENT_KEY);
localStorage.removeItem(TELEMETRY_FIRST_USE_KEY);
} catch {
// Continue clearing the in-memory and SDK identity if storage is blocked.
}
clearPendingCloudTelemetryConsent();
try {
sessionStorage.removeItem(TELEMETRY_SESSION_KEY);
} catch {
// Continue clearing the in-memory and SDK identity if storage is blocked.
}
// Reset PostHog if initialized
if (isInitialized && posthogInstance) {
posthogInstance.reset();
desiredTelemetryIdentity = null;
desiredIdentityRevision += 1;
appliedIdentityRevision = -1;
try {
if (posthogInstance) {
resetPostHogIdentity(posthogInstance, true);
}
} catch {
// Ignore storage errors
// A reset failure must not leave capture enabled after a privacy clear.
try {
posthogInstance?.opt_out_capturing();
} catch {
// Telemetry failures must not break the application.
}
}
}
/**
* Get the PostHog instance for advanced usage (if needed).
* Returns the instance if initialized, otherwise null.
* Note: This is async because PostHog is lazily loaded.
*/
export async function getPostHogInstance(): Promise<PostHog | null> {
if (!isInitialized) {
return null;
}
return posthogInstance;
}
+3 -6
View File
@@ -230,12 +230,9 @@ const THEME_STYLE_TAG_ID = "oh-color-theme-override";
* Why doubled selectors + re-append on every call:
* "Later sheet wins the tie" cannot be relied on: in the built SPA
* (ssr:false, prerendered shell) React 19 re-creates the <head> elements it
* manages (<Meta/>/<Links/>) whenever the tree above the router remounts —
* e.g. PostHogWrapper swapping in <PostHogProvider> once the analytics key
* resolves, which only happens in builds with VITE_POSTHOG_CLIENT_KEY baked
* (the published npm package / tagged Docker images). That re-inserts the
* base stylesheet <link> AFTER this tag, which would let the base sheet's
* unlayered [data-agent-server-ui] variable rules (0,1,0) win every tie.
* manages (<Meta/>/<Links/>) whenever the tree above the router remounts.
* That can re-insert the base stylesheet <link> AFTER this tag, allowing its
* unlayered [data-agent-server-ui] variable rules (0,1,0) to win every tie.
* Doubling the attribute selectors ([x][x], 0,2,0) beats them from any
* position in <head>; re-appending on each apply keeps document order
* favorable as well.
+3 -12
View File
@@ -1,23 +1,14 @@
import type { PostHog } from "posthog-js";
import { trackException } from "#/services/telemetry";
interface ErrorDetails {
message: string;
source?: string;
metadata?: Record<string, unknown>;
msgId?: string;
posthog?: PostHog;
}
export function trackError({
message,
source,
metadata = {},
posthog,
}: ErrorDetails) {
if (!posthog) return;
export function trackError({ message, source, metadata = {} }: ErrorDetails) {
const error = new Error(message);
posthog.captureException(error, {
void trackException(error, {
error_source: source || "unknown",
...metadata,
});
-21
View File
@@ -1,21 +0,0 @@
import type { PostHog } from "posthog-js";
/**
* Handle user consent for tracking
* @param posthog PostHog instance (from usePostHog hook)
* @param consent Whether the user consents to tracking
*/
export const handleCaptureConsent = (
posthog: PostHog | undefined,
consent: boolean,
) => {
if (!posthog) return;
if (consent && !posthog.has_opted_in_capturing()) {
posthog.opt_in_capturing();
}
if (!consent && !posthog.has_opted_out_capturing()) {
posthog.opt_out_capturing();
}
};
-1
View File
@@ -240,7 +240,6 @@ export default defineConfig(({ mode }) => {
// Pre-bundle ALL dependencies to prevent runtime optimization and page reloads
// These are discovered during initial app load:
"posthog-js",
"posthog-js/react",
"@tanstack/react-query",
"react-hot-toast",
"i18next",