Skip to content

Commit 8ebc719

Browse files
committed
feat: add PostHog telemetry provider for cloud builds
- Add posthog-js dependency - Create PostHogTelemetryProvider following MixpanelTelemetryProvider pattern - Register provider in initTelemetry.ts - Read posthog_project_token and posthog_api_host from window.__CONFIG__ (runtime config from /features endpoint) - Tree-shaken away in OSS builds (verified)
1 parent fe8ab1d commit 8ebc719

8 files changed

Lines changed: 839 additions & 67 deletions

File tree

global.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ interface Window {
3333
gtm_container_id?: string
3434
ga_measurement_id?: string
3535
mixpanel_token?: string
36+
posthog_project_token?: string
37+
posthog_api_host?: string
3638
require_whitelist?: boolean
3739
subscription_required?: boolean
3840
max_upload_size?: number

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"loglevel": "^1.9.2",
101101
"marked": "^15.0.11",
102102
"pinia": "catalog:",
103+
"posthog-js": "catalog:",
103104
"primeicons": "catalog:",
104105
"primevue": "catalog:",
105106
"reka-ui": "catalog:",

pnpm-lock.yaml

Lines changed: 217 additions & 65 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ catalog:
8686
picocolors: ^1.1.1
8787
pinia: ^3.0.4
8888
postcss-html: ^1.8.0
89+
posthog-js: ^1.358.1
8990
pretty-bytes: ^7.1.0
9091
primeicons: ^7.0.0
9192
primevue: ^4.2.5

src/platform/remoteConfig/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export type RemoteConfig = {
2929
gtm_container_id?: string
3030
ga_measurement_id?: string
3131
mixpanel_token?: string
32+
posthog_project_token?: string
33+
posthog_api_host?: string
3234
subscription_required?: boolean
3335
server_health_alert?: ServerHealthAlert
3436
max_upload_size?: number

src/platform/telemetry/initTelemetry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,21 @@ export async function initTelemetry(): Promise<void> {
2424
{ TelemetryRegistry },
2525
{ MixpanelTelemetryProvider },
2626
{ GtmTelemetryProvider },
27-
{ ImpactTelemetryProvider }
27+
{ ImpactTelemetryProvider },
28+
{ PostHogTelemetryProvider }
2829
] = await Promise.all([
2930
import('./TelemetryRegistry'),
3031
import('./providers/cloud/MixpanelTelemetryProvider'),
3132
import('./providers/cloud/GtmTelemetryProvider'),
32-
import('./providers/cloud/ImpactTelemetryProvider')
33+
import('./providers/cloud/ImpactTelemetryProvider'),
34+
import('./providers/cloud/PostHogTelemetryProvider')
3335
])
3436

3537
const registry = new TelemetryRegistry()
3638
registry.registerProvider(new MixpanelTelemetryProvider())
3739
registry.registerProvider(new GtmTelemetryProvider())
3840
registry.registerProvider(new ImpactTelemetryProvider())
41+
registry.registerProvider(new PostHogTelemetryProvider())
3942

4043
setTelemetryRegistry(registry)
4144
})()
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { TelemetryEvents } from '../../types'
4+
5+
const hoisted = vi.hoisted(() => {
6+
const mockCapture = vi.fn()
7+
const mockInit = vi.fn()
8+
const mockIdentify = vi.fn()
9+
const mockPeopleSet = vi.fn()
10+
const mockOnUserResolved = vi.fn()
11+
12+
return {
13+
mockCapture,
14+
mockInit,
15+
mockIdentify,
16+
mockPeopleSet,
17+
mockOnUserResolved,
18+
mockPosthog: {
19+
default: {
20+
init: mockInit,
21+
capture: mockCapture,
22+
identify: mockIdentify,
23+
people: { set: mockPeopleSet }
24+
}
25+
}
26+
}
27+
})
28+
29+
vi.mock('vue', async () => {
30+
const actual = await vi.importActual('vue')
31+
return {
32+
...actual,
33+
watch: vi.fn()
34+
}
35+
})
36+
37+
vi.mock('@/composables/auth/useCurrentUser', () => ({
38+
useCurrentUser: () => ({
39+
onUserResolved: hoisted.mockOnUserResolved
40+
})
41+
}))
42+
43+
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
44+
remoteConfig: { value: null }
45+
}))
46+
47+
vi.mock('posthog-js', () => hoisted.mockPosthog)
48+
49+
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
50+
51+
function createProvider(
52+
config: Partial<typeof window.__CONFIG__> = {}
53+
): PostHogTelemetryProvider {
54+
const original = window.__CONFIG__
55+
window.__CONFIG__ = { ...original, ...config }
56+
const provider = new PostHogTelemetryProvider()
57+
window.__CONFIG__ = original
58+
return provider
59+
}
60+
61+
describe('PostHogTelemetryProvider', () => {
62+
beforeEach(() => {
63+
vi.clearAllMocks()
64+
window.__CONFIG__ = {
65+
posthog_project_token: 'phc_test_token'
66+
} as typeof window.__CONFIG__
67+
})
68+
69+
describe('initialization', () => {
70+
it('disables itself when posthog_project_token is not provided', async () => {
71+
const provider = createProvider({ posthog_project_token: undefined })
72+
await vi.dynamicImportSettled()
73+
74+
provider.trackSignupOpened()
75+
76+
expect(hoisted.mockCapture).not.toHaveBeenCalled()
77+
})
78+
79+
it('calls posthog.init with the token and default api_host', async () => {
80+
createProvider()
81+
await vi.dynamicImportSettled()
82+
83+
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
84+
api_host: 'https://ph.comfy.org',
85+
autocapture: false,
86+
capture_pageview: false,
87+
capture_pageleave: false,
88+
persistence: 'localStorage+cookie'
89+
})
90+
})
91+
92+
it('uses custom api_host from config when provided', async () => {
93+
window.__CONFIG__ = {
94+
posthog_project_token: 'phc_test_token',
95+
posthog_api_host: 'https://custom.host.com'
96+
} as typeof window.__CONFIG__
97+
new PostHogTelemetryProvider()
98+
await vi.dynamicImportSettled()
99+
100+
expect(hoisted.mockInit).toHaveBeenCalledWith(
101+
'phc_test_token',
102+
expect.objectContaining({ api_host: 'https://custom.host.com' })
103+
)
104+
})
105+
106+
it('registers onUserResolved callback after init', async () => {
107+
createProvider()
108+
await vi.dynamicImportSettled()
109+
110+
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
111+
})
112+
113+
it('identifies user when onUserResolved fires', async () => {
114+
createProvider()
115+
await vi.dynamicImportSettled()
116+
117+
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
118+
callback({ id: 'user-123' })
119+
120+
expect(hoisted.mockIdentify).toHaveBeenCalledWith('user-123')
121+
})
122+
})
123+
124+
describe('event tracking', () => {
125+
it('captures events after initialization', async () => {
126+
const provider = createProvider()
127+
await vi.dynamicImportSettled()
128+
129+
provider.trackSignupOpened()
130+
131+
expect(hoisted.mockCapture).toHaveBeenCalledWith(
132+
TelemetryEvents.USER_SIGN_UP_OPENED,
133+
{}
134+
)
135+
})
136+
137+
it('captures events with metadata', async () => {
138+
const provider = createProvider()
139+
await vi.dynamicImportSettled()
140+
141+
provider.trackAuth({ method: 'google' })
142+
143+
expect(hoisted.mockCapture).toHaveBeenCalledWith(
144+
TelemetryEvents.USER_AUTH_COMPLETED,
145+
{ method: 'google' }
146+
)
147+
})
148+
149+
it('queues events before initialization and flushes after', async () => {
150+
const provider = createProvider()
151+
152+
provider.trackUserLoggedIn()
153+
expect(hoisted.mockCapture).not.toHaveBeenCalled()
154+
155+
await vi.dynamicImportSettled()
156+
157+
expect(hoisted.mockCapture).toHaveBeenCalledWith(
158+
TelemetryEvents.USER_LOGGED_IN,
159+
{}
160+
)
161+
})
162+
})
163+
164+
describe('disabled events', () => {
165+
it('does not capture default disabled events', async () => {
166+
const provider = createProvider()
167+
await vi.dynamicImportSettled()
168+
169+
provider.trackWorkflowOpened({
170+
missing_node_count: 0,
171+
missing_node_types: []
172+
})
173+
174+
expect(hoisted.mockCapture).not.toHaveBeenCalled()
175+
})
176+
177+
it('captures events not in the disabled list', async () => {
178+
const provider = createProvider()
179+
await vi.dynamicImportSettled()
180+
181+
provider.trackMonthlySubscriptionSucceeded()
182+
183+
expect(hoisted.mockCapture).toHaveBeenCalledWith(
184+
TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED,
185+
{}
186+
)
187+
})
188+
})
189+
190+
describe('survey tracking', () => {
191+
it('sets user properties on survey submission', async () => {
192+
const provider = createProvider()
193+
await vi.dynamicImportSettled()
194+
195+
const responses = { familiarity: 'beginner', industry: 'tech' }
196+
provider.trackSurvey('submitted', responses)
197+
198+
expect(hoisted.mockCapture).toHaveBeenCalledWith(
199+
TelemetryEvents.USER_SURVEY_SUBMITTED,
200+
expect.objectContaining({ familiarity: 'beginner' })
201+
)
202+
expect(hoisted.mockPeopleSet).toHaveBeenCalled()
203+
})
204+
205+
it('does not set user properties on survey opened', async () => {
206+
const provider = createProvider()
207+
await vi.dynamicImportSettled()
208+
209+
provider.trackSurvey('opened')
210+
211+
expect(hoisted.mockCapture).toHaveBeenCalledWith(
212+
TelemetryEvents.USER_SURVEY_OPENED,
213+
{}
214+
)
215+
expect(hoisted.mockPeopleSet).not.toHaveBeenCalled()
216+
})
217+
})
218+
219+
describe('page view', () => {
220+
it('captures page view with page_name property', async () => {
221+
const provider = createProvider()
222+
await vi.dynamicImportSettled()
223+
224+
provider.trackPageView('workflow_editor')
225+
226+
expect(hoisted.mockCapture).toHaveBeenCalledWith(
227+
TelemetryEvents.PAGE_VIEW,
228+
{ page_name: 'workflow_editor' }
229+
)
230+
})
231+
})
232+
})

0 commit comments

Comments
 (0)