From 2453df79e832ab3e9868b446ef0969047d354c3e Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:21:55 +0000 Subject: [PATCH 1/2] fix(core): keep session-attribution props on minimal $feature_flag_called events The minimal `$feature_flag_called` allowlist (MINIMAL_FLAG_CALLED_EVENT_PROPERTIES) rebuilt the event from a strict allowlist that omitted every referrer and `utm_*`/campaign param. The server session table derives session-initial UTM and channel type from whichever event lands first in a session, so a minimal flag-called event firing first nulled out the whole session's attribution. Add the referrer and campaign-param keys (kept in sync with the browser SDK's CAMPAIGN_PARAMS and the server session table) to the allowlist so they survive minimization. Entries are inert on SDKs that never set them. Generated-By: PostHog Code Task-Id: 9dd7e155-821a-4fe9-93bf-1b7e9c0a1c06 --- .changeset/tidy-flags-keep-utm.md | 5 +++ .../src/__tests__/featureflags.test.ts | 41 +++++++++++++++++++ packages/core/src/featureFlagUtils.ts | 33 +++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 .changeset/tidy-flags-keep-utm.md diff --git a/.changeset/tidy-flags-keep-utm.md b/.changeset/tidy-flags-keep-utm.md new file mode 100644 index 0000000000..076d6accff --- /dev/null +++ b/.changeset/tidy-flags-keep-utm.md @@ -0,0 +1,5 @@ +--- +'@posthog/core': patch +--- + +Keep session-attribution properties (referrer and `utm_*`/campaign params) on minimal `$feature_flag_called` events. Previously the minimal allowlist stripped every campaign param, so a flag-called event landing first in a session set the whole session's UTM attribution and channel type to NULL in web analytics. diff --git a/packages/browser/src/__tests__/featureflags.test.ts b/packages/browser/src/__tests__/featureflags.test.ts index f1e1a58261..eb52d188ea 100644 --- a/packages/browser/src/__tests__/featureflags.test.ts +++ b/packages/browser/src/__tests__/featureflags.test.ts @@ -4582,6 +4582,10 @@ describe('minimal $feature_flag_called events', () => { '$groups', '$current_url', '$pathname', + // session-level attribution props survive minimization so a flag-called + // event firing first doesn't null out the session's UTM/channel + '$referrer', + '$referring_domain', '$session_id', '$window_id', '$lib', @@ -4602,6 +4606,41 @@ describe('minimal $feature_flag_called events', () => { expect(event.$set_once).toBeUndefined() }) + it('keeps session-attribution campaign params so a first-in-session flag event does not null out UTM', async () => { + const { posthog, events } = await createInstanceWithCapturedEvents() + // Campaign params are stored as bare-named super properties (see posthog-persistence + // update_campaign_params -> register). They feed the server session table's + // session-initial UTM, so they must survive minimization. + posthog.register({ + utm_source: 'newsletter', + utm_medium: 'email', + utm_campaign: 'summer_sale', + utm_content: 'cta_button', + utm_term: 'analytics', + gclid: 'abc123', + // a non-attribution super property that must still be stripped + super_prop: 'super_value', + }) + posthog.featureFlags.receivedFeatureFlags( + gatedFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false }) + ) + + expect(posthog.getFeatureFlag('test-flag')).toBe(true) + + const event = findFlagCalledEvent(events) + expect(event).toBeDefined() + expect(event.properties).toMatchObject({ + utm_source: 'newsletter', + utm_medium: 'email', + utm_campaign: 'summer_sale', + utm_content: 'cta_button', + utm_term: 'analytics', + gclid: 'abc123', + }) + // non-attribution super properties are still structurally excluded + expect(event.properties).not.toHaveProperty('super_prop') + }) + it('strips the timestamp-override props when captured with an explicit timestamp', async () => { const { posthog, events } = await createInstanceWithCapturedEvents() posthog.featureFlags.receivedFeatureFlags( @@ -4629,6 +4668,8 @@ describe('minimal $feature_flag_called events', () => { '$feature_flag_request_id', '$current_url', '$pathname', + '$referrer', + '$referring_domain', '$session_id', '$window_id', '$lib', diff --git a/packages/core/src/featureFlagUtils.ts b/packages/core/src/featureFlagUtils.ts index d6e9704b6c..a51a7bc7b4 100644 --- a/packages/core/src/featureFlagUtils.ts +++ b/packages/core/src/featureFlagUtils.ts @@ -209,6 +209,39 @@ export const MINIMAL_FLAG_CALLED_EVENT_PROPERTIES: readonly string[] = [ // Debug location '$current_url', '$pathname', + // Session-level attribution (referrer + campaign params). The server session table + // derives session-initial UTM and channel type from whichever event lands first in a + // session, so if a minimal `$feature_flag_called` event arrives first these keys must + // survive minimization — otherwise the whole session's attribution is set to NULL. + '$referrer', + '$referring_domain', + // Campaign params are stored as super properties under their bare names (no `$` + // prefix) and must be kept in sync with the browser SDK's `CAMPAIGN_PARAMS` and the + // server-side session table. Entries are inert on SDKs that never set them. + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'gad_source', + 'mc_cid', + 'gclid', + 'gclsrc', + 'dclid', + 'gbraid', + 'wbraid', + 'fbclid', + 'msclkid', + 'twclid', + 'li_fat_id', + 'igshid', + 'ttclid', + 'rdt_cid', + 'epik', + 'qclid', + 'sccid', + 'irclid', + '_kx', // Linkage / SDK identity '$session_id', '$window_id', From bf0bf21f41af05a2b78c3d12dc3ed00f9ad4080b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 3 Aug 2026 11:03:49 +0200 Subject: [PATCH 2/2] fix: complete minimal flag attribution coverage --- .changeset/tidy-flags-keep-utm.md | 4 +- .../src/__tests__/featureflags.test.ts | 42 ++++++------- packages/core/src/featureFlagUtils.ts | 60 ++++++++++--------- packages/core/src/index.ts | 1 + .../src/__tests__/feature-flags.flags.spec.ts | 32 +++++++++- 5 files changed, 84 insertions(+), 55 deletions(-) diff --git a/.changeset/tidy-flags-keep-utm.md b/.changeset/tidy-flags-keep-utm.md index 076d6accff..b6324d400e 100644 --- a/.changeset/tidy-flags-keep-utm.md +++ b/.changeset/tidy-flags-keep-utm.md @@ -1,5 +1,7 @@ --- '@posthog/core': patch +'posthog-js': patch +'posthog-node': patch --- -Keep session-attribution properties (referrer and `utm_*`/campaign params) on minimal `$feature_flag_called` events. Previously the minimal allowlist stripped every campaign param, so a flag-called event landing first in a session set the whole session's UTM attribution and channel type to NULL in web analytics. +Keep `$referring_domain` and canonical `utm_*`/campaign parameters on minimal `$feature_flag_called` events. Previously the minimal allowlist stripped every campaign parameter, so a flag-called event landing first in a session could set the session's UTM attribution and channel type to NULL in web analytics. diff --git a/packages/browser/src/__tests__/featureflags.test.ts b/packages/browser/src/__tests__/featureflags.test.ts index eb52d188ea..c4984406ee 100644 --- a/packages/browser/src/__tests__/featureflags.test.ts +++ b/packages/browser/src/__tests__/featureflags.test.ts @@ -8,11 +8,12 @@ import { } from '../posthog-featureflags' import { PostHogPersistence } from '../posthog-persistence' import { RequestRouter } from '../utils/request-router' -import { isUndefined } from '@posthog/core' +import { isUndefined, MINIMAL_FLAG_CALLED_EVENT_CAMPAIGN_PROPERTIES } from '@posthog/core' import { PostHogConfig } from '../types' import { createMockPostHog, createPosthogInstance } from './helpers/posthog-instance' import { SimpleEventEmitter } from '@posthog/browser-common/utils/simple-event-emitter' import { uuidv7 } from '@posthog/browser-common/utils/uuidv7' +import { CAMPAIGN_PARAMS } from '@posthog/browser-common/utils/event-utils' jest.useFakeTimers() jest.spyOn(global, 'setTimeout') @@ -4584,7 +4585,6 @@ describe('minimal $feature_flag_called events', () => { '$pathname', // session-level attribution props survive minimization so a flag-called // event firing first doesn't null out the session's UTM/channel - '$referrer', '$referring_domain', '$session_id', '$window_id', @@ -4606,20 +4606,19 @@ describe('minimal $feature_flag_called events', () => { expect(event.$set_once).toBeUndefined() }) - it('keeps session-attribution campaign params so a first-in-session flag event does not null out UTM', async () => { + it('keeps every canonical session-attribution campaign param without widening the minimal event', async () => { const { posthog, events } = await createInstanceWithCapturedEvents() - // Campaign params are stored as bare-named super properties (see posthog-persistence - // update_campaign_params -> register). They feed the server session table's - // session-initial UTM, so they must survive minimization. + const campaignProperties = Object.fromEntries(CAMPAIGN_PARAMS.map((key) => [key, `value-for-${key}`])) + + // Keep the shared minimal-event set exhaustively synchronized with the canonical + // browser campaign set without copying that list into this test. + expect(MINIMAL_FLAG_CALLED_EVENT_CAMPAIGN_PROPERTIES).toEqual(CAMPAIGN_PARAMS) + posthog.register({ - utm_source: 'newsletter', - utm_medium: 'email', - utm_campaign: 'summer_sale', - utm_content: 'cta_button', - utm_term: 'analytics', - gclid: 'abc123', - // a non-attribution super property that must still be stripped - super_prop: 'super_value', + ...campaignProperties, + $referring_domain: 'referring.example', + $referrer: 'https://referring.example/path?private=value', + unrelated_superproperty: 'must-be-stripped', }) posthog.featureFlags.receivedFeatureFlags( gatedFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false }) @@ -4630,15 +4629,11 @@ describe('minimal $feature_flag_called events', () => { const event = findFlagCalledEvent(events) expect(event).toBeDefined() expect(event.properties).toMatchObject({ - utm_source: 'newsletter', - utm_medium: 'email', - utm_campaign: 'summer_sale', - utm_content: 'cta_button', - utm_term: 'analytics', - gclid: 'abc123', - }) - // non-attribution super properties are still structurally excluded - expect(event.properties).not.toHaveProperty('super_prop') + ...campaignProperties, + $referring_domain: 'referring.example', + }) + expect(event.properties).not.toHaveProperty('$referrer') + expect(event.properties).not.toHaveProperty('unrelated_superproperty') }) it('strips the timestamp-override props when captured with an explicit timestamp', async () => { @@ -4668,7 +4663,6 @@ describe('minimal $feature_flag_called events', () => { '$feature_flag_request_id', '$current_url', '$pathname', - '$referrer', '$referring_domain', '$session_id', '$window_id', diff --git a/packages/core/src/featureFlagUtils.ts b/packages/core/src/featureFlagUtils.ts index a51a7bc7b4..a121fb4391 100644 --- a/packages/core/src/featureFlagUtils.ts +++ b/packages/core/src/featureFlagUtils.ts @@ -189,35 +189,7 @@ export const flagDetailsToResults = (flagDetails: Record { await posthog.shutdown() }) + it('keeps every campaign attribution property on the remote-evaluation minimization path', async () => { + mockedFetch.mockImplementation( + apiImplementationV4(remoteFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: false })) + ) + const { posthog, captured } = createClient() + const campaignProperties = Object.fromEntries( + MINIMAL_FLAG_CALLED_EVENT_CAMPAIGN_PROPERTIES.map((key) => [key, `value-for-${key}`]) + ) + await posthog.register({ + ...campaignProperties, + $referring_domain: 'referring.example', + $referrer: 'https://referring.example/path?private=value', + unrelated_superproperty: 'must-be-stripped', + }) + + await posthog.getFeatureFlagResult('test-flag', 'some-distinct-id') + await waitForPromises() + + const message = findFlagCalledEvent(captured) + expect(message).toBeDefined() + expect(message.properties).toMatchObject({ + ...campaignProperties, + $referring_domain: 'referring.example', + }) + expect(message.properties).not.toHaveProperty('$referrer') + expect(message.properties).not.toHaveProperty('unrelated_superproperty') + + await posthog.shutdown() + }) + it('sends the full event when gated but the flag has an experiment', async () => { mockedFetch.mockImplementation( apiImplementationV4(remoteFlagsResponse({ minimalFlagCalledEvents: true, hasExperiment: true }))