Skip to content

Commit 90a0f14

Browse files
authored
fix: include initial person props in $identify when group() called first (#2725)
Skip $set_once calculation for $groupidentify events since server strips person properties from them anyway. This prevents the flag from being set prematurely, ensuring UTM params are included with subsequent $identify.
1 parent 8fd3eaa commit 90a0f14

File tree

5 files changed

+138
-20
lines changed

5 files changed

+138
-20
lines changed

.changeset/wicked-papayas-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': patch
3+
---
4+
5+
fix: include initial person props in $identify when group() called first
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Test to verify bug: calling group() before identify() causes initial person props to be lost
3+
*/
4+
import { createPosthogInstance } from './helpers/posthog-instance'
5+
import { uuidv7 } from '../uuidv7'
6+
7+
jest.mock('../utils/globals', () => {
8+
const orig = jest.requireActual('../utils/globals')
9+
const mockURLGetter = jest.fn()
10+
const mockReferrerGetter = jest.fn()
11+
return {
12+
...orig,
13+
mockURLGetter,
14+
mockReferrerGetter,
15+
document: {
16+
...orig.document,
17+
createElement: (...args: any[]) => orig.document.createElement(...args),
18+
body: {},
19+
get referrer() {
20+
return mockReferrerGetter()
21+
},
22+
get URL() {
23+
return mockURLGetter()
24+
},
25+
},
26+
get location() {
27+
const url = mockURLGetter()
28+
return {
29+
href: url,
30+
toString: () => url,
31+
}
32+
},
33+
}
34+
})
35+
36+
// eslint-disable-next-line @typescript-eslint/no-require-imports
37+
const { mockURLGetter, mockReferrerGetter } = require('../utils/globals')
38+
39+
describe('group before identify bug', () => {
40+
beforeEach(() => {
41+
mockReferrerGetter.mockReturnValue('https://referrer.com')
42+
mockURLGetter.mockReturnValue('https://example.com?utm_source=linkedin&utm_campaign=test')
43+
})
44+
45+
it('should include initial UTM params in $identify even when group() is called first', async () => {
46+
const token = uuidv7()
47+
const beforeSendMock = jest.fn().mockImplementation((e) => e)
48+
49+
const posthog = await createPosthogInstance(token, {
50+
before_send: beforeSendMock,
51+
person_profiles: 'identified_only',
52+
})
53+
54+
// Simulate what Clerk does: call group() with properties before identify()
55+
posthog.group('organization', 'org_123', { name: 'Acme Corp' })
56+
57+
// Then call identify
58+
posthog.identify('user_123')
59+
60+
// Find the events
61+
const calls = beforeSendMock.mock.calls
62+
const groupIdentifyCall = calls.find((c: any) => c[0].event === '$groupidentify')
63+
const identifyCall = calls.find((c: any) => c[0].event === '$identify')
64+
65+
console.log(
66+
'$groupidentify $set_once.$initial_utm_source:',
67+
groupIdentifyCall?.[0]?.$set_once?.$initial_utm_source
68+
)
69+
console.log('$identify $set_once.$initial_utm_source:', identifyCall?.[0]?.$set_once?.$initial_utm_source)
70+
71+
// THE BUG: $identify should have $set_once with initial UTM params
72+
// but because group() was called first, _personProcessingSetOncePropertiesSent is already true
73+
expect(identifyCall).toBeDefined()
74+
expect(identifyCall[0].$set_once).toBeDefined()
75+
expect(identifyCall[0].$set_once.$initial_utm_source).toEqual('linkedin')
76+
expect(identifyCall[0].$set_once.$initial_utm_campaign).toEqual('test')
77+
})
78+
79+
it('should include initial UTM params when identify() is called without group() first', async () => {
80+
const token = uuidv7()
81+
const beforeSendMock = jest.fn().mockImplementation((e) => e)
82+
83+
const posthog = await createPosthogInstance(token, {
84+
before_send: beforeSendMock,
85+
person_profiles: 'identified_only',
86+
})
87+
88+
// Just call identify without group first
89+
posthog.identify('user_123')
90+
91+
const calls = beforeSendMock.mock.calls
92+
const identifyCall = calls.find((c: any) => c[0].event === '$identify')
93+
94+
console.log('$identify $set_once (no group):', JSON.stringify(identifyCall?.[0]?.$set_once, null, 2))
95+
96+
// This should work
97+
expect(identifyCall).toBeDefined()
98+
expect(identifyCall[0].$set_once).toBeDefined()
99+
expect(identifyCall[0].$set_once.$initial_utm_source).toEqual('linkedin')
100+
expect(identifyCall[0].$set_once.$initial_utm_campaign).toEqual('test')
101+
})
102+
})

packages/browser/src/__tests__/personProcessing.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,8 @@ describe('person processing', () => {
563563
expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(true)
564564
})
565565

566-
it('should not send the $groupidentify event if person_processing is set to never', async () => {
566+
it('should send the $groupidentify event even if person_processing is set to never', async () => {
567+
// Groups are separate from person processing - $groupidentify should always be sent
567568
// arrange
568569
const { posthog, beforeSendMock } = await setup('never')
569570

@@ -573,15 +574,20 @@ describe('person processing', () => {
573574
posthog.capture('custom event after group')
574575

575576
// assert
577+
// setGroupPropertiesForFlags still has a person processing check
576578
expect(mockLogger.error).toBeCalledTimes(1)
577579
expect(mockLogger.error).toHaveBeenCalledWith(
578-
'posthog.group was called, but process_person is set to "never". This call will be ignored.'
580+
'posthog.setGroupPropertiesForFlags was called, but process_person is set to "never". This call will be ignored.'
579581
)
580582

581-
expect(beforeSendMock).toBeCalledTimes(2)
583+
// $groupidentify is sent (groups are independent of person processing)
584+
expect(beforeSendMock).toBeCalledTimes(3)
582585
const eventBeforeGroup = beforeSendMock.mock.calls[0]
583586
expect(eventBeforeGroup[0].properties.$process_person_profile).toEqual(false)
584-
const eventAfterGroup = beforeSendMock.mock.calls[1]
587+
const groupIdentify = beforeSendMock.mock.calls[1]
588+
expect(groupIdentify[0].event).toEqual('$groupidentify')
589+
// $groupidentify doesn't set $process_person_profile since it doesn't process persons
590+
const eventAfterGroup = beforeSendMock.mock.calls[2]
585591
expect(eventAfterGroup[0].properties.$process_person_profile).toEqual(false)
586592
})
587593
})

packages/browser/src/extensions/product-tours/product-tour.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@
187187
border-radius: 6px;
188188
border: none;
189189
cursor: pointer;
190-
transition: opacity 0.15s ease, transform 0.1s ease;
190+
transition:
191+
opacity 0.15s ease,
192+
transform 0.1s ease;
191193
}
192194

193195
.ph-tour-button:hover {
@@ -225,7 +227,9 @@
225227
color: var(--ph-tour-text-secondary-color);
226228
font-size: 18px;
227229
line-height: 1;
228-
transition: background 0.15s ease, color 0.15s ease;
230+
transition:
231+
background 0.15s ease,
232+
color 0.15s ease;
229233
}
230234

231235
.ph-tour-dismiss:hover {

packages/browser/src/posthog-core.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -593,13 +593,10 @@ export class PostHog {
593593
if (this._hasBootstrappedFeatureFlags()) {
594594
const activeFlags = Object.keys(config.bootstrap?.featureFlags || {})
595595
.filter((flag) => !!config.bootstrap?.featureFlags?.[flag])
596-
.reduce(
597-
(res: Record<string, string | boolean>, key) => (
598-
(res[key] = config.bootstrap?.featureFlags?.[key] || false),
599-
res
600-
),
601-
{}
602-
)
596+
.reduce((res: Record<string, string | boolean>, key) => {
597+
res[key] = config.bootstrap?.featureFlags?.[key] || false
598+
return res
599+
}, {})
603600
const featureFlagPayloads = Object.keys(config.bootstrap?.featureFlagPayloads || {})
604601
.filter((key) => activeFlags[key])
605602
.reduce((res: Record<string, JsonType>, key) => {
@@ -1144,7 +1141,11 @@ export class PostHog {
11441141
if (setProperties) {
11451142
data.$set = options?.$set
11461143
}
1147-
const setOnceProperties = this._calculate_set_once_properties(options?.$set_once)
1144+
// $groupidentify doesn't process person $set_once on the server, so don't mark
1145+
// initial person props as sent. This ensures they're included with subsequent
1146+
// $identify calls.
1147+
const markSetOnceAsSent = event_name !== '$groupidentify'
1148+
const setOnceProperties = this._calculate_set_once_properties(options?.$set_once, markSetOnceAsSent)
11481149
if (setOnceProperties) {
11491150
data.$set_once = setOnceProperties
11501151
}
@@ -1369,8 +1370,10 @@ export class PostHog {
13691370
* profile with mostly-accurate properties, despite earlier events not setting them. We do this by storing them in
13701371
* persistence.
13711372
* @param dataSetOnce
1373+
* @param markAsSent - if true, marks the properties as sent so they won't be included in future events.
1374+
* Set to false for events like $groupidentify where the server doesn't process person props.
13721375
*/
1373-
_calculate_set_once_properties(dataSetOnce?: Properties): Properties | undefined {
1376+
_calculate_set_once_properties(dataSetOnce?: Properties, markAsSent: boolean = true): Properties | undefined {
13741377
if (!this.persistence || !this._hasPersonProcessing()) {
13751378
return dataSetOnce
13761379
}
@@ -1389,7 +1392,9 @@ export class PostHog {
13891392
logger.error('sanitize_properties is deprecated. Use before_send instead')
13901393
setOnceProperties = sanitize_properties(setOnceProperties, '$set_once')
13911394
}
1392-
this._personProcessingSetOncePropertiesSent = true
1395+
if (markAsSent) {
1396+
this._personProcessingSetOncePropertiesSent = true
1397+
}
13931398
if (isEmptyObject(setOnceProperties)) {
13941399
return undefined
13951400
}
@@ -2270,10 +2275,6 @@ export class PostHog {
22702275
return
22712276
}
22722277

2273-
if (!this._requirePersonProcessing('posthog.group')) {
2274-
return
2275-
}
2276-
22772278
const existingGroups = this.getGroups()
22782279

22792280
// if group key changes, remove stored group properties

0 commit comments

Comments
 (0)