Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ 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 identity through `setTelemetryIdentity()` and captures through the service; it never receives 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 event context through `setTelemetryCloudContext()` and captures through the service; it never receives, identifies, 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.
- `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.
- `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` 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.
- 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.
- A business milestone has one canonical event capture. Do not conditionally switch between telemetry and app clients or emit duplicate events.

### Cloud funnel observability
Expand Down
32 changes: 19 additions & 13 deletions __tests__/hooks/use-telemetry-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ vi.mock("#/hooks/query/use-cloud-current-user-id", () => ({
useCloudCurrentUserId: () => useCloudCurrentUserIdMock(),
}));

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

import { useTelemetryIdentity } from "#/hooks/use-telemetry-identity";
Expand All @@ -40,10 +40,11 @@ describe("useTelemetryIdentity", () => {
});
});

it("declares the current Cloud identity", () => {
it("declares the current Cloud user context", () => {
renderHook(() => useTelemetryIdentity());

expect(setTelemetryIdentityMock).toHaveBeenCalledWith("user-123", {
expect(setTelemetryCloudContextMock).toHaveBeenCalledWith({
userId: "user-123",
email: "user@example.com",
});
});
Expand All @@ -53,23 +54,27 @@ describe("useTelemetryIdentity", () => {
data: { email: "", git_user_email: "git@example.com" },
});
const { rerender } = renderHook(() => useTelemetryIdentity());
expect(setTelemetryIdentityMock).toHaveBeenLastCalledWith("user-123", {
expect(setTelemetryCloudContextMock).toHaveBeenLastCalledWith({
userId: "user-123",
email: "git@example.com",
});

useSettingsMock.mockReturnValue({ data: {} });
rerender();
expect(setTelemetryIdentityMock).toHaveBeenLastCalledWith("user-123", {});
expect(setTelemetryCloudContextMock).toHaveBeenLastCalledWith({
userId: "user-123",
email: undefined,
});
});

it("waits until the Cloud identity query settles", () => {
it("preserves Cloud user context while the identity query loads", () => {
useCloudCurrentUserIdMock.mockReturnValue({
[BACKEND_ID]: { userId: null, isLoading: true },
});

renderHook(() => useTelemetryIdentity());

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

it("declares logout only after the Cloud identity query settles", () => {
Expand All @@ -79,15 +84,15 @@ describe("useTelemetryIdentity", () => {

renderHook(() => useTelemetryIdentity());

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

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

renderHook(() => useTelemetryIdentity());

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

it("declares a changed Cloud account", () => {
Expand All @@ -98,7 +103,8 @@ describe("useTelemetryIdentity", () => {

rerender();

expect(setTelemetryIdentityMock).toHaveBeenLastCalledWith("user-456", {
expect(setTelemetryCloudContextMock).toHaveBeenLastCalledWith({
userId: "user-456",
email: "user@example.com",
});
});
Expand Down
45 changes: 3 additions & 42 deletions __tests__/hooks/use-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ vi.mock("#/api/automation-service/automation-service.api", () => ({

import { useTracking } from "#/hooks/use-tracking";

const TEST_EMAIL = "user@example.com";
// Resolved at test-run time so it matches whatever URL jsdom is configured
// with in the current environment (varies between local and CI).
let COMMON: Record<string, unknown>;
Expand All @@ -44,11 +43,10 @@ describe("useTracking", () => {
compatibilityMocks.getCachedAgentServerVersion.mockReturnValue(null);

useSettingsMock.mockReturnValue({
data: { email: TEST_EMAIL, user_consents_to_analytics: true },
data: { email: "user@example.com", user_consents_to_analytics: true },
});
COMMON = {
current_url: window.location.href,
user_email: TEST_EMAIL,
};
});

Expand Down Expand Up @@ -423,7 +421,7 @@ describe("useTracking", () => {
describe("shared telemetry boundary", () => {
it("delegates consent enforcement when backend settings report false", () => {
useSettingsMock.mockReturnValue({
data: { email: TEST_EMAIL, user_consents_to_analytics: false },
data: { email: "user@example.com", user_consents_to_analytics: false },
});

getTracking().trackPushButtonClick();
Expand All @@ -436,7 +434,7 @@ describe("useTracking", () => {

it("delegates consent enforcement when backend settings report null", () => {
useSettingsMock.mockReturnValue({
data: { email: TEST_EMAIL, user_consents_to_analytics: null },
data: { email: "user@example.com", user_consents_to_analytics: null },
});

getTracking().trackPushButtonClick();
Expand All @@ -463,45 +461,8 @@ describe("useTracking", () => {
backend_kind: "cloud",
connection_method: "cloud_login",
source: "onboarding",
user_email: null,
}),
);
});
});

describe("commonProperties", () => {
it("uses git_user_email as fallback when email is absent", () => {
useSettingsMock.mockReturnValue({
data: {
email: null,
git_user_email: "git@example.com",
user_consents_to_analytics: true,
},
});

getTracking().trackPushButtonClick();

expect(captureMock).toHaveBeenCalledWith(
"push_button_clicked",
expect.objectContaining({ user_email: "git@example.com" }),
);
});

it("sends null user_email when no email fields are present", () => {
useSettingsMock.mockReturnValue({
data: {
email: null,
git_user_email: null,
user_consents_to_analytics: true,
},
});

getTracking().trackPushButtonClick();

expect(captureMock).toHaveBeenCalledWith(
"push_button_clicked",
expect.objectContaining({ user_email: null }),
);
});
});
});
87 changes: 87 additions & 0 deletions __tests__/services/telemetry-bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

type MockPostHog = {
init: ReturnType<typeof vi.fn>;
capture: ReturnType<typeof vi.fn>;
captureException: ReturnType<typeof vi.fn>;
opt_in_capturing: ReturnType<typeof vi.fn>;
opt_out_capturing: ReturnType<typeof vi.fn>;
has_opted_out_capturing: ReturnType<typeof vi.fn>;
identify: ReturnType<typeof vi.fn>;
get_property: ReturnType<typeof vi.fn>;
reset: ReturnType<typeof vi.fn>;
};

async function loadTelemetryWithLegacyUser() {
let identifiedUserId: string | undefined = "legacy-cloud-user";
const mockPosthog: 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),
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);

vi.doMock("posthog-js", () => ({
default: mockPosthog,
}));

const telemetry = await import("#/services/telemetry");
return { telemetry, mockPosthog };
}

describe("Telemetry bootstrap identity migration", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
delete (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
});

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

await telemetry.setTelemetryConsent("granted");

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

it("preserves OAuth bootstrap identity instead of resetting legacy users", async () => {
const { telemetry, mockPosthog } = await loadTelemetryWithLegacyUser();

telemetry.configurePostHogBootstrap({
distinctID: "bootstrapped-browser-id",
sessionID: "bootstrapped-session-id",
});
await telemetry.setTelemetryConsent("granted");

expect(mockPosthog.reset).not.toHaveBeenCalled();
expect(mockPosthog.init).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
bootstrap: {
distinctID: "bootstrapped-browser-id",
sessionID: "bootstrapped-session-id",
},
person_profiles: "always",
}),
"agent-canvas",
);
expect(mockPosthog.identify).not.toHaveBeenCalled();
});
});
Loading
Loading