Skip to content

Commit 405b686

Browse files
Share cloud task API transport
Generated-By: PostHog Code Task-Id: 40c57a59-b4e1-4760-8e56-ecd03e9c2f0f
1 parent 018a562 commit 405b686

15 files changed

Lines changed: 1542 additions & 66 deletions

apps/mobile/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
"@expo/ui": "0.2.0-beta.9",
2828
"@modelcontextprotocol/ext-apps": "^1.2.2",
2929
"@modelcontextprotocol/sdk": "^1.29.0",
30+
"@posthog/api-client": "workspace:*",
31+
"@posthog/core": "workspace:*",
3032
"@posthog/shared": "workspace:*",
3133
"@react-native-async-storage/async-storage": "^2.2.0",
3234
"@react-native-community/netinfo": "^12.0.1",

apps/mobile/src/lib/analytics.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { PostHogEventProperties } from "@posthog/core";
2-
import { usePostHog } from "posthog-react-native";
1+
import { type PostHog, usePostHog } from "posthog-react-native";
32
import { useEffect, useMemo } from "react";
43

54
/**
@@ -199,6 +198,8 @@ export interface Analytics {
199198
): void;
200199
}
201200

201+
type PostHogCaptureProperties = Parameters<PostHog["capture"]>[1];
202+
202203
// Client discriminator stamped on inbox events so the shared PostHog project
203204
// can be sliced by surface (desktop sends "code", the web frontend sends
204205
// "cloud"). Mirrors packages/ui/src/shell/posthogAnalyticsImpl.ts.
@@ -221,12 +222,9 @@ export function useAnalytics(): Analytics {
221222
const enriched = INBOX_ANALYTICS_EVENT_NAMES.has(eventName)
222223
? { inbox_client: INBOX_CLIENT, ...properties }
223224
: properties;
224-
// Our typed property interfaces don't carry an index signature; cast
225-
// to the wider PostHog event-properties shape without losing the
226-
// narrower call-site type-check.
227225
posthog?.capture(
228226
eventName,
229-
enriched as unknown as PostHogEventProperties,
227+
enriched as unknown as PostHogCaptureProperties,
230228
);
231229
},
232230
}),

apps/mobile/src/lib/api.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@ import Constants from "expo-constants";
33
import { useAuthStore } from "@/features/auth";
44
import { logger } from "@/lib/logger";
55

6+
export class HttpError extends Error {
7+
constructor(
8+
readonly status: number,
9+
readonly statusText: string,
10+
message: string,
11+
) {
12+
super(message);
13+
this.name = "HttpError";
14+
}
15+
}
16+
617
// Derive the init shape directly from expo/fetch so we don't import from
718
// expo's internal build output (which can move between versions).
8-
type FetchInit = NonNullable<Parameters<typeof fetch>[1]>;
19+
export type FetchInit = NonNullable<Parameters<typeof fetch>[1]>;
920

1021
const log = logger.scope("api");
1122

@@ -66,7 +77,7 @@ export function createTimeoutSignal(ms: number): AbortSignal {
6677
// pending refresh across all callers and reset it once it settles.
6778
let pendingRefresh: Promise<void> | null = null;
6879

69-
async function refreshAccessTokenOnce(): Promise<void> {
80+
export async function refreshAccessTokenOnce(): Promise<void> {
7081
if (pendingRefresh) return pendingRefresh;
7182
const promise = useAuthStore
7283
.getState()
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
authState: {
5+
cloudRegion: "us" as string | null,
6+
getCloudUrlFromRegion: vi.fn(() => "https://us.posthog.com"),
7+
oauthAccessToken: "access-token" as string | null,
8+
projectId: 123 as number | null,
9+
refreshAccessToken: vi.fn(async () => {}),
10+
},
11+
expoApplication: {
12+
nativeApplicationVersion: "1.2.3" as string | null,
13+
},
14+
expoConstants: {
15+
expoConfig: { version: "9.9.9" } as { version?: string } | null,
16+
},
17+
expoFetch: vi.fn(),
18+
instances: [] as Array<{
19+
apiHost: string;
20+
getAccessToken: () => Promise<string>;
21+
refreshAccessToken: () => Promise<string>;
22+
teamId: number | undefined;
23+
options: Record<string, unknown>;
24+
setTeamId: ReturnType<typeof vi.fn>;
25+
}>,
26+
}));
27+
28+
vi.mock("@posthog/api-client/posthog-client", () => ({
29+
PostHogAPIClient: class {
30+
setTeamId = vi.fn();
31+
32+
constructor(
33+
apiHost: string,
34+
getAccessToken: () => Promise<string>,
35+
refreshAccessToken: () => Promise<string>,
36+
teamId: number | undefined,
37+
options: Record<string, unknown>,
38+
) {
39+
mocks.instances.push({
40+
apiHost,
41+
getAccessToken,
42+
refreshAccessToken,
43+
teamId,
44+
options,
45+
setTeamId: this.setTeamId,
46+
});
47+
}
48+
},
49+
}));
50+
51+
vi.mock("expo-application", () => ({
52+
get nativeApplicationVersion() {
53+
return mocks.expoApplication.nativeApplicationVersion;
54+
},
55+
}));
56+
57+
vi.mock("expo-constants", () => ({
58+
default: {
59+
get expoConfig() {
60+
return mocks.expoConstants.expoConfig;
61+
},
62+
},
63+
}));
64+
65+
vi.mock("expo/fetch", () => ({ fetch: mocks.expoFetch }));
66+
67+
vi.mock("@/features/auth", () => ({
68+
useAuthStore: {
69+
getState: () => mocks.authState,
70+
},
71+
}));
72+
73+
beforeEach(() => {
74+
vi.resetModules();
75+
vi.clearAllMocks();
76+
mocks.instances.length = 0;
77+
mocks.authState.cloudRegion = "us";
78+
mocks.authState.oauthAccessToken = "access-token";
79+
mocks.authState.projectId = 123;
80+
mocks.authState.getCloudUrlFromRegion.mockReturnValue(
81+
"https://us.posthog.com",
82+
);
83+
mocks.authState.refreshAccessToken.mockImplementation(async () => {});
84+
mocks.expoApplication.nativeApplicationVersion = "1.2.3";
85+
mocks.expoConstants.expoConfig = { version: "9.9.9" };
86+
});
87+
88+
describe("createPostHogApiClient", () => {
89+
it("configures the shared client for the mobile host", async () => {
90+
const { createPostHogApiClient } = await import("./posthogApiClient");
91+
92+
createPostHogApiClient();
93+
94+
expect(mocks.instances).toHaveLength(1);
95+
expect(mocks.instances[0]).toMatchObject({
96+
apiHost: "https://us.posthog.com",
97+
teamId: 123,
98+
options: {
99+
appVersion: "1.2.3",
100+
fetch: mocks.expoFetch,
101+
githubConnectFrom: "posthog_mobile",
102+
userAgent: "posthog/mobile.hog.dev; version: 1.2.3",
103+
},
104+
});
105+
});
106+
107+
it("falls back to the Expo config version", async () => {
108+
mocks.expoApplication.nativeApplicationVersion = null;
109+
mocks.expoConstants.expoConfig = { version: "4.5.6" };
110+
const { createPostHogApiClient } = await import("./posthogApiClient");
111+
112+
createPostHogApiClient();
113+
114+
expect(mocks.instances[0]?.options).toMatchObject({
115+
appVersion: "4.5.6",
116+
userAgent: "posthog/mobile.hog.dev; version: 4.5.6",
117+
});
118+
});
119+
120+
it("returns the refreshed token from the current auth store state", async () => {
121+
mocks.authState.refreshAccessToken.mockImplementation(async () => {
122+
mocks.authState.oauthAccessToken = "refreshed-token";
123+
});
124+
const { createPostHogApiClient } = await import("./posthogApiClient");
125+
createPostHogApiClient();
126+
127+
await expect(mocks.instances[0]?.refreshAccessToken()).resolves.toBe(
128+
"refreshed-token",
129+
);
130+
expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce();
131+
});
132+
133+
it("shares one refresh across concurrent client retries", async () => {
134+
let resolveRefresh: (() => void) | undefined;
135+
mocks.authState.refreshAccessToken.mockImplementation(
136+
() =>
137+
new Promise<void>((resolve) => {
138+
resolveRefresh = () => {
139+
mocks.authState.oauthAccessToken = "refreshed-token";
140+
resolve();
141+
};
142+
}),
143+
);
144+
const { createPostHogApiClient } = await import("./posthogApiClient");
145+
createPostHogApiClient();
146+
147+
const refreshes = [
148+
mocks.instances[0]?.refreshAccessToken(),
149+
mocks.instances[0]?.refreshAccessToken(),
150+
];
151+
expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce();
152+
resolveRefresh?.();
153+
154+
await expect(Promise.all(refreshes)).resolves.toEqual([
155+
"refreshed-token",
156+
"refreshed-token",
157+
]);
158+
});
159+
});
160+
161+
describe("getPostHogApiClient", () => {
162+
it("reuses the regional client and updates its project", async () => {
163+
const { getPostHogApiClient } = await import("./posthogApiClient");
164+
165+
const first = getPostHogApiClient();
166+
mocks.authState.projectId = 456;
167+
const second = getPostHogApiClient();
168+
169+
expect(second).toBe(first);
170+
expect(mocks.instances).toHaveLength(1);
171+
expect(mocks.instances[0]?.setTeamId).toHaveBeenCalledWith(456);
172+
});
173+
174+
it("creates a new client when the cloud region changes", async () => {
175+
const { getPostHogApiClient } = await import("./posthogApiClient");
176+
177+
const first = getPostHogApiClient();
178+
mocks.authState.cloudRegion = "eu";
179+
mocks.authState.getCloudUrlFromRegion.mockReturnValue(
180+
"https://eu.posthog.com",
181+
);
182+
const second = getPostHogApiClient();
183+
184+
expect(second).not.toBe(first);
185+
expect(mocks.instances.map(({ apiHost }) => apiHost)).toEqual([
186+
"https://us.posthog.com",
187+
"https://eu.posthog.com",
188+
]);
189+
});
190+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { FetchImplementation } from "@posthog/api-client/fetcher";
2+
import { PostHogAPIClient } from "@posthog/api-client/posthog-client";
3+
import { fetch } from "expo/fetch";
4+
import * as Application from "expo-application";
5+
import Constants from "expo-constants";
6+
import { useAuthStore } from "@/features/auth";
7+
import { refreshAccessTokenOnce } from "@/lib/api";
8+
9+
const MOBILE_GITHUB_CONNECT_FROM = "posthog_mobile";
10+
11+
let posthogApiClient: PostHogAPIClient | null = null;
12+
let posthogApiHost: string | null = null;
13+
14+
function getAppVersion(): string {
15+
return (
16+
Application.nativeApplicationVersion ??
17+
Constants.expoConfig?.version ??
18+
"unknown"
19+
);
20+
}
21+
22+
function getAuthenticatedContext(): {
23+
apiHost: string;
24+
projectId: number;
25+
} {
26+
const { cloudRegion, getCloudUrlFromRegion, projectId } =
27+
useAuthStore.getState();
28+
29+
if (!cloudRegion) {
30+
throw new Error("No cloud region set");
31+
}
32+
if (!projectId) {
33+
throw new Error("No project ID set");
34+
}
35+
36+
return {
37+
apiHost: getCloudUrlFromRegion(cloudRegion),
38+
projectId,
39+
};
40+
}
41+
42+
async function getAccessToken(): Promise<string> {
43+
const { oauthAccessToken } = useAuthStore.getState();
44+
if (!oauthAccessToken) {
45+
throw new Error("Not authenticated");
46+
}
47+
return oauthAccessToken;
48+
}
49+
50+
async function refreshAccessToken(): Promise<string> {
51+
await refreshAccessTokenOnce();
52+
return getAccessToken();
53+
}
54+
55+
export function createPostHogApiClient(): PostHogAPIClient {
56+
const { apiHost, projectId } = getAuthenticatedContext();
57+
const appVersion = getAppVersion();
58+
59+
return new PostHogAPIClient(
60+
apiHost,
61+
getAccessToken,
62+
refreshAccessToken,
63+
projectId,
64+
{
65+
appVersion,
66+
fetch: fetch as FetchImplementation,
67+
githubConnectFrom: MOBILE_GITHUB_CONNECT_FROM,
68+
userAgent: `posthog/mobile.hog.dev; version: ${appVersion}`,
69+
},
70+
);
71+
}
72+
73+
export function getPostHogApiClient(): PostHogAPIClient {
74+
const { apiHost, projectId } = getAuthenticatedContext();
75+
76+
if (!posthogApiClient || posthogApiHost !== apiHost) {
77+
posthogApiClient = createPostHogApiClient();
78+
posthogApiHost = apiHost;
79+
} else {
80+
posthogApiClient.setTeamId(projectId);
81+
}
82+
83+
return posthogApiClient;
84+
}

packages/api-client/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
"src/**/*"
2626
],
2727
"dependencies": {
28-
"@posthog/agent": "workspace:*",
2928
"@posthog/shared": "workspace:*"
3029
}
3130
}

packages/api-client/src/fetcher.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,45 @@ describe("buildApiFetcher", () => {
5353
expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe(
5454
"Bearer my-token",
5555
);
56+
expect(mockFetch.mock.calls[0][1].headers.get("User-Agent")).toBe(
57+
"posthog/desktop.hog.dev; version: test",
58+
);
59+
});
60+
61+
it("uses an injected fetch implementation and custom user agent", async () => {
62+
const injectedFetch = vi.fn().mockResolvedValueOnce(ok());
63+
const fetcher = buildApiFetcher({
64+
getAccessToken: vi.fn().mockResolvedValue("token"),
65+
refreshAccessToken: vi.fn().mockResolvedValue("new-token"),
66+
appVersion: "1.2.3",
67+
fetch: injectedFetch,
68+
userAgent: "posthog/mobile; version: 1.2.3",
69+
});
70+
71+
await fetcher.fetch(mockInput);
72+
73+
expect(injectedFetch).toHaveBeenCalledTimes(1);
74+
expect(mockFetch).not.toHaveBeenCalled();
75+
expect(injectedFetch.mock.calls[0][1].headers.get("User-Agent")).toBe(
76+
"posthog/mobile; version: 1.2.3",
77+
);
78+
});
79+
80+
it("omits the user agent when explicitly disabled", async () => {
81+
const injectedFetch = vi.fn().mockResolvedValueOnce(ok());
82+
const fetcher = buildApiFetcher({
83+
getAccessToken: vi.fn().mockResolvedValue("token"),
84+
refreshAccessToken: vi.fn().mockResolvedValue("new-token"),
85+
appVersion: "1.2.3",
86+
fetch: injectedFetch,
87+
userAgent: null,
88+
});
89+
90+
await fetcher.fetch(mockInput);
91+
92+
expect(injectedFetch.mock.calls[0][1].headers.has("User-Agent")).toBe(
93+
false,
94+
);
5695
});
5796

5897
it("retries once with a freshly fetched token on 401", async () => {

0 commit comments

Comments
 (0)