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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ by a human, report the exact validator error rather than editing them yourself.

One Canvas-owned PostHog client owns telemetry and app analytics.

- `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 Cloud user event context through `setTelemetryCloudContext()` and captures through the service; it never receives, identifies, or resets the SDK client.
- `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 Cloud user identity and event context through the service and captures through the service; it never receives, identifies, or resets the SDK client directly.
- `TelemetryProvider` configures bootstrap/runtime options and eagerly initializes the service. It does not expose PostHog context or maintain a second client lifecycle.
- The default PostHog key and direct ingestion host live in `config/defaults.json` under `telemetry`. Local launchers (`dev-with-automation`, `dev-static`, published binary path) and Docker default `AUTOMATION_POSTHOG_API_KEY` from explicit automation env, then `VITE_POSTHOG_API_KEY`, then that shared default key, so the automation backend can emit local consent-gated telemetry without extra user config. Keep `VITE_DO_NOT_TRACK=1` disabling the zero-config default.
- 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. Consent does not identify PostHog as the Cloud user; Canvas keeps the browser/install distinct ID stable across local/Cloud backend switches, creates anonymous person profiles for analytics, and attaches Cloud account context as event properties (`cloud_user_id`, `cloud_user_email`) only while a Cloud backend is active. Legacy identified users are reset only when the PostHog client initializes without OAuth bootstrap IDs, and the reset immediately restores the canonical consent state that the PostHog SDK clears.
- `canvas_install` fires once, pre-consent, with the client's anonymous distinct ID. After consent and Cloud authentication, Canvas identifies PostHog with the stable Cloud user ID so PostHog joins the earlier anonymous activity to that person. Merely switching to a local backend clears Cloud event context without resetting the identified person; a resolved logout/account change, consent revocation, or privacy clear owns the reset. Local-only and never-authenticated traffic remains on the anonymous browser/install ID. Cloud account context (`cloud_user_id`, `cloud_user_email`, `cloud_org_id`) is attached as event properties only while a Cloud backend is active.
- `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` and `useTelemetry` remain the public library telemetry API for npm consumers (the `TelemetryConsentBanner` component was removed; hosts needing a consent UI build their own on `useTelemetry`). 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` automatically and captures through the telemetry service; Cloud account email is attached centrally as `cloud_user_email` while Cloud context is active. 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.
Expand Down
60 changes: 60 additions & 0 deletions __tests__/contexts/active-backend-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { setTelemetryCloudContextMock, setTelemetryIdentityMock } = vi.hoisted(
() => ({
setTelemetryCloudContextMock: vi.fn(),
setTelemetryIdentityMock: vi.fn(),
}),
);
vi.mock("#/services/telemetry", () => ({
setTelemetryCloudContext: setTelemetryCloudContextMock,
setTelemetryIdentity: setTelemetryIdentityMock,
}));
import {
__resetActiveStoreForTests,
NO_BACKEND_ID,
Expand Down Expand Up @@ -35,6 +46,7 @@ beforeEach(() => {
vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
__resetActiveStoreForTests();
__resetHealthStoreForTests();
vi.clearAllMocks();
});

afterEach(() => {
Expand Down Expand Up @@ -229,6 +241,54 @@ describe("ActiveBackendProvider", () => {

// Assert
expect(getBackendHealthEntry(id)).toBeNull();
expect(
result.current.backends.find((backend) => backend.id === id),
).toHaveProperty("connectionRevision", 1);
});

it("clears identity and re-keys data when active Cloud credentials change", () => {
const { result } = renderHook(() => useActiveBackendContext(), {
wrapper: makeWrapper(),
});
let id = "";
act(() => {
id = result.current.addBackend({
name: "Cloud",
host: "https://app.all-hands.dev",
apiKey: "old-key",
kind: "cloud",
}).id;
});

act(() => {
result.current.updateBackend(id, { apiKey: "new-key" });
});

expect(result.current.active.backend.connectionRevision).toBe(1);
expect(setTelemetryCloudContextMock).toHaveBeenCalledWith(null);
expect(setTelemetryIdentityMock).toHaveBeenCalledWith(null);
});

it("clears identity when the active Cloud backend is removed", () => {
const { result } = renderHook(() => useActiveBackendContext(), {
wrapper: makeWrapper(),
});
let id = "";
act(() => {
id = result.current.addBackend({
name: "Cloud",
host: "https://app.all-hands.dev",
apiKey: "key",
kind: "cloud",
}).id;
});

act(() => {
result.current.removeBackend(id);
});

expect(setTelemetryCloudContextMock).toHaveBeenCalledWith(null);
expect(setTelemetryIdentityMock).toHaveBeenCalledWith(null);
});

it("removeBackend drops the backend's persisted health entry", () => {
Expand Down
12 changes: 12 additions & 0 deletions __tests__/hooks/use-sync-automation-telemetry-consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const state = {
backendHost: "http://localhost:8000",
backendApiKey: "key-1",
consent: "pending" as "pending" | "granted" | "denied",
pendingRevocationId: null as string | null,
};
const listeners = new Set<() => void>();

Expand All @@ -30,6 +31,7 @@ vi.mock("#/contexts/active-backend-context", () => ({
}));

vi.mock("#/services/telemetry", () => ({
getPendingLocalTelemetryRevocationId: () => state.pendingRevocationId,
getTelemetryConsent: () => state.consent,
subscribeTelemetryConsent: (listener: () => void) => {
listeners.add(listener);
Expand All @@ -51,6 +53,7 @@ describe("useSyncAutomationTelemetryConsent", () => {

state.backendKind = "local";
state.consent = "pending";
state.pendingRevocationId = null;
});

it("does not call the local automation consent API while consent is pending", () => {
Expand All @@ -59,6 +62,15 @@ describe("useSyncAutomationTelemetryConsent", () => {
expect(syncTelemetryConsentMock).not.toHaveBeenCalled();
});

it("revokes the pre-reset actor after a privacy clear", () => {
state.pendingRevocationId = "user-before-reset";

renderHook(() => useSyncAutomationTelemetryConsent());

expect(syncTelemetryConsentMock).toHaveBeenCalledOnce();
expect(syncTelemetryConsentMock).toHaveBeenCalledWith("denied");
});

it("does not call the local automation consent API for cloud backends", () => {
state.backendKind = "cloud";
state.consent = "granted";
Expand Down
31 changes: 30 additions & 1 deletion __tests__/hooks/use-telemetry-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ vi.mock("#/hooks/query/use-cloud-current-user-id", () => ({
}));

const setTelemetryCloudContextMock = vi.fn();
const setTelemetryIdentityMock = vi.fn();
vi.mock("#/services/telemetry", () => ({
setTelemetryCloudContext: (...args: unknown[]) =>
setTelemetryCloudContextMock(...args),
setTelemetryIdentity: (...args: unknown[]) =>
setTelemetryIdentityMock(...args),
}));

import { useTelemetryIdentity } from "#/hooks/use-telemetry-identity";
Expand Down Expand Up @@ -51,6 +54,9 @@ describe("useTelemetryIdentity", () => {
email: "user@example.com",
orgId: "org-123",
});
expect(setTelemetryIdentityMock).toHaveBeenCalledWith("user-123", {
email: "user@example.com",
});
});

it("falls back to the git email and omits an absent email", () => {
Expand Down Expand Up @@ -91,14 +97,19 @@ describe("useTelemetryIdentity", () => {
renderHook(() => useTelemetryIdentity());

expect(setTelemetryCloudContextMock).toHaveBeenCalledWith(null);
expect(setTelemetryIdentityMock).toHaveBeenCalledWith(null);
});

it("clears Cloud user context while a local backend is active", () => {
useActiveBackendMock.mockReturnValue({ backend: localBackend, orgId: null });
useActiveBackendMock.mockReturnValue({
backend: localBackend,
orgId: null,
});

renderHook(() => useTelemetryIdentity());

expect(setTelemetryCloudContextMock).toHaveBeenCalledWith(null);
expect(setTelemetryIdentityMock).not.toHaveBeenCalled();
});

it("declares a changed Cloud account", () => {
Expand All @@ -114,5 +125,23 @@ describe("useTelemetryIdentity", () => {
email: "user@example.com",
orgId: "org-123",
});
expect(setTelemetryIdentityMock).toHaveBeenLastCalledWith("user-456", {
email: "user@example.com",
});
});

it("redeclares the user after Cloud connection credentials change", () => {
const { rerender } = renderHook(() => useTelemetryIdentity());
vi.clearAllMocks();
useActiveBackendMock.mockReturnValue({
backend: { ...cloudBackend, connectionRevision: 1 },
orgId: "org-123",
});

rerender();

expect(setTelemetryIdentityMock).toHaveBeenCalledWith("user-123", {
email: "user@example.com",
});
});
});
4 changes: 2 additions & 2 deletions __tests__/services/telemetry-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ describe("Telemetry bootstrap identity migration", () => {
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
});

it("clears a legacy identified user when no OAuth bootstrap is present", async () => {
it("preserves an identified user while Cloud identity is unresolved", async () => {
const { telemetry, mockPosthog } = await loadTelemetryWithLegacyUser();

await telemetry.setTelemetryConsent("granted");

expect(mockPosthog.reset).toHaveBeenCalledWith(false);
expect(mockPosthog.reset).not.toHaveBeenCalled();
expect(mockPosthog.opt_in_capturing).toHaveBeenCalled();
expect(mockPosthog.identify).not.toHaveBeenCalled();
});
Expand Down
Loading
Loading