Skip to content

Commit 752c747

Browse files
feat: attach PostHog groups to wizard analytics events (#591)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 025ab55 commit 752c747

3 files changed

Lines changed: 151 additions & 2 deletions

File tree

src/lib/agent/agent-runner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
OutroKind,
2222
} from '@lib/wizard-session';
2323
import { getOrAskForProjectData } from '@utils/setup-utils';
24-
import { analytics } from '@utils/analytics';
24+
import { analytics, groupsFromUser } from '@utils/analytics';
2525
import { getUI } from '@ui';
2626
import {
2727
initializeAgent,
@@ -280,6 +280,8 @@ export async function runProgram(
280280
getUI().setRoleAtOrganization(roleAtOrganization);
281281
getUI().setApiUser(user);
282282

283+
analytics.setGroups(groupsFromUser(user, host));
284+
283285
// 5. Skill install (if skillId provided)
284286
let skillPath: string | undefined;
285287
if (config.skillId) {

src/utils/__tests__/analytics.test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Analytics } from '@utils/analytics';
1+
import { Analytics, groupsFromUser } from '@utils/analytics';
22
import { PostHog } from 'posthog-node';
33
import { v4 as uuidv4 } from 'uuid';
44
import { ANALYTICS_TEAM_TAG } from '@lib/constants';
5+
import type { ApiUser } from '@lib/api';
56

67
jest.mock('posthog-node');
78
jest.mock('uuid');
@@ -154,6 +155,120 @@ describe('Analytics', () => {
154155
});
155156
});
156157

158+
describe('groups (before_send injection)', () => {
159+
type TestEvent = Record<string, unknown> & {
160+
groups?: Record<string, string>;
161+
};
162+
type BeforeSendFn = (event: TestEvent | null) => TestEvent | null;
163+
164+
const getBeforeSend = (): BeforeSendFn =>
165+
(MockedPostHog.mock.calls[0][1] as { before_send: BeforeSendFn })
166+
.before_send;
167+
168+
it('does not attach groups before setGroups is called', () => {
169+
const beforeSend = getBeforeSend();
170+
const event = { event: 'x', distinctId: 'd', properties: {} };
171+
172+
expect(beforeSend(event)).toBe(event);
173+
expect(event).not.toHaveProperty('groups');
174+
});
175+
176+
it('injects the active group map into every event', () => {
177+
analytics.setGroups({
178+
instance: 'https://us.posthog.com',
179+
organization: 'org-1',
180+
project: 'team-uuid',
181+
});
182+
const beforeSend = getBeforeSend();
183+
184+
const result = beforeSend({
185+
event: 'x',
186+
distinctId: 'd',
187+
properties: {},
188+
});
189+
190+
expect(result?.groups).toEqual({
191+
instance: 'https://us.posthog.com',
192+
organization: 'org-1',
193+
project: 'team-uuid',
194+
});
195+
});
196+
197+
it('lets per-event groups override the active map', () => {
198+
analytics.setGroups({ instance: 'https://us.posthog.com', project: 'a' });
199+
const beforeSend = getBeforeSend();
200+
201+
const result = beforeSend({
202+
event: 'x',
203+
distinctId: 'd',
204+
properties: {},
205+
groups: { project: 'override' },
206+
});
207+
208+
expect(result?.groups).toEqual({
209+
instance: 'https://us.posthog.com',
210+
project: 'override',
211+
});
212+
});
213+
214+
it('passes null events through untouched', () => {
215+
analytics.setGroups({ instance: 'https://us.posthog.com' });
216+
const beforeSend = getBeforeSend();
217+
218+
expect(beforeSend(null)).toBeNull();
219+
});
220+
});
221+
222+
describe('groupsFromUser', () => {
223+
const userWith = (overrides: Partial<ApiUser>): ApiUser =>
224+
({
225+
distinct_id: 'd',
226+
organization: { id: 'org-1' },
227+
team: { id: 1, uuid: 'team-uuid', organization: 'org-1' },
228+
organizations: [],
229+
...overrides,
230+
} as unknown as ApiUser);
231+
232+
it('always includes the host as the instance group', () => {
233+
expect(groupsFromUser(null, 'https://us.posthog.com')).toEqual({
234+
instance: 'https://us.posthog.com',
235+
});
236+
});
237+
238+
it('maps org id, customer id, and team uuid (not numeric project id)', () => {
239+
const user = userWith({
240+
organization: {
241+
id: 'org-uuid',
242+
customer_id: 'cus_123',
243+
} as ApiUser['organization'],
244+
team: {
245+
id: 42,
246+
uuid: 'team-uuid',
247+
organization: 'org-uuid',
248+
} as ApiUser['team'],
249+
});
250+
251+
expect(groupsFromUser(user, 'https://eu.posthog.com')).toEqual({
252+
instance: 'https://eu.posthog.com',
253+
organization: 'org-uuid',
254+
customer: 'cus_123',
255+
project: 'team-uuid',
256+
});
257+
});
258+
259+
it('omits optional keys that are absent', () => {
260+
const user = userWith({
261+
organization: { id: 'org-uuid' } as ApiUser['organization'],
262+
team: { id: 42, organization: 'org-uuid' } as ApiUser['team'],
263+
});
264+
265+
expect(groupsFromUser(user, 'https://us.posthog.com')).toEqual({
266+
instance: 'https://us.posthog.com',
267+
organization: 'org-uuid',
268+
});
269+
});
270+
});
271+
157272
describe('integration with other methods', () => {
158273
it('should work correctly with setTag and captureException', () => {
159274
const error = new Error('Test error');

src/utils/analytics.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ANALYTICS_TEAM_TAG,
66
} from '@lib/constants';
77
import type { WizardSession } from '@lib/wizard-session';
8+
import type { ApiUser } from '@lib/api';
89
import { v4 as uuidv4 } from 'uuid';
910
import { debug } from './debug';
1011

@@ -25,6 +26,26 @@ export function sessionProperties(
2526
run_phase: session.runPhase,
2627
};
2728
}
29+
30+
export function groupsFromUser(
31+
user: ApiUser | null,
32+
host: string,
33+
): Record<string, string> {
34+
const groups: Record<string, string> = { instance: host };
35+
if (!user) return groups;
36+
37+
const organizationId = user.organization?.id;
38+
if (organizationId) groups.organization = organizationId;
39+
40+
const customerId = user.organization?.customer_id;
41+
if (customerId) groups.customer = customerId;
42+
43+
const projectUuid = user.team?.uuid;
44+
if (projectUuid) groups.project = projectUuid;
45+
46+
return groups;
47+
}
48+
2849
export class Analytics {
2950
private client: PostHog;
3051
private tags: Record<string, string | boolean | number | null | undefined> =
@@ -33,13 +54,20 @@ export class Analytics {
3354
private anonymousId: string;
3455
private appName = 'wizard';
3556
private activeFlags: Record<string, string> | null = null;
57+
private groups: Record<string, string> = {};
3658

3759
constructor() {
3860
this.client = new PostHog(ANALYTICS_POSTHOG_PUBLIC_PROJECT_WRITE_KEY, {
3961
host: ANALYTICS_HOST_URL,
4062
flushAt: 1,
4163
flushInterval: 0,
4264
enableExceptionAutocapture: true,
65+
before_send: (event) => {
66+
if (event && Object.keys(this.groups).length > 0) {
67+
event.groups = { ...this.groups, ...event.groups };
68+
}
69+
return event;
70+
},
4371
});
4472

4573
this.tags = { $app_name: this.appName };
@@ -61,6 +89,10 @@ export class Analytics {
6189
this.tags[key] = value;
6290
}
6391

92+
setGroups(groups: Record<string, string>) {
93+
this.groups = groups;
94+
}
95+
6496
captureException(error: Error, properties: Record<string, unknown> = {}) {
6597
this.client.captureException(error, this.distinctId ?? this.anonymousId, {
6698
team: ANALYTICS_TEAM_TAG,

0 commit comments

Comments
 (0)