mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-09-28 05:54:50 +08:00
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:
committed by
GitHub
co-authored by
neubig
parent
1f404b3774
commit
3598bb14e3
+4
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -92,4 +92,4 @@ describe("AppSettingsScreen", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Vendored
-3
@@ -3,7 +3,4 @@
|
||||
|
||||
interface Window {
|
||||
__GITHUB_CLIENT_ID__?: string | null;
|
||||
posthog?: {
|
||||
capture: (event: string, properties?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
-6
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user