diff --git a/.changeset/dirty-news-drive.md b/.changeset/dirty-news-drive.md new file mode 100644 index 0000000000..398e233075 --- /dev/null +++ b/.changeset/dirty-news-drive.md @@ -0,0 +1,5 @@ +--- +'posthog-js': patch +--- + +Include $device_id when fetching feature flags diff --git a/packages/browser/functional_tests/feature-flags.test.ts b/packages/browser/functional_tests/feature-flags.test.ts index c43d9602de..92e5fa1069 100644 --- a/packages/browser/functional_tests/feature-flags.test.ts +++ b/packages/browser/functional_tests/feature-flags.test.ts @@ -27,6 +27,7 @@ describe('FunctionalTests / Feature Flags', () => { expect(getRequests(token)['/flags/']).toEqual([ // This is the initial call to the flags endpoint on PostHog init. { + $device_id: anonymousId, distinct_id: anonymousId, person_properties: {}, groups: {}, @@ -53,6 +54,7 @@ describe('FunctionalTests / Feature Flags', () => { // `identify()`. { $anon_distinct_id: anonymousId, + $device_id: anonymousId, distinct_id: 'test-id', person_properties: { $initial__kx: null, @@ -102,6 +104,7 @@ describe('FunctionalTests / Feature Flags', () => { expect(getRequests(token)['/flags/']).toEqual([ // This is the initial call to the flags endpoint on PostHog init. { + $device_id: anonymousId, distinct_id: anonymousId, person_properties: {}, groups: {}, @@ -125,6 +128,7 @@ describe('FunctionalTests / Feature Flags', () => { // `identify()`. { $anon_distinct_id: anonymousId, + $device_id: anonymousId, distinct_id: 'test-id', groups: {}, person_properties: { @@ -172,6 +176,7 @@ describe('FunctionalTests / Feature Flags', () => { await waitFor(() => { expect(getRequests(token)['/flags/']).toEqual([ { + $device_id: anonymousId, distinct_id: 'test-id', groups: {}, person_properties: { @@ -221,6 +226,7 @@ describe('FunctionalTests / Feature Flags', () => { expect(getRequests(token)['/flags/']).toEqual([ // This is the initial call to the flags endpoint on PostHog init. { + $device_id: anonymousId, distinct_id: anonymousId, person_properties: {}, groups: {}, @@ -248,6 +254,7 @@ describe('FunctionalTests / Feature Flags', () => { expect(getRequests(token)['/flags/']).toEqual([ { $anon_distinct_id: anonymousId, + $device_id: anonymousId, distinct_id: 'test-id', groups: {}, person_properties: { @@ -304,6 +311,7 @@ describe('FunctionalTests / Feature Flags', () => { // This is the initial call to the flags endpoint on PostHog init, with all info added from `loaded`. { $anon_distinct_id: 'anon-id', + $device_id: 'anon-id', distinct_id: 'test-id', groups: { playlist: 'id:77' }, person_properties: { diff --git a/packages/browser/playwright/mocked/flags.spec.ts b/packages/browser/playwright/mocked/flags.spec.ts index d963b02798..14ab070241 100644 --- a/packages/browser/playwright/mocked/flags.spec.ts +++ b/packages/browser/playwright/mocked/flags.spec.ts @@ -68,6 +68,7 @@ test.describe('flags', () => { expect(flagsPayload).toEqual({ token: 'test token', distinct_id: 'new-id', + $device_id: flagsPayload.$device_id, person_properties: { $initial__kx: null, $initial_current_url: 'http://localhost:2345/playground/cypress/index.html', diff --git a/packages/browser/src/__tests__/featureflags.test.ts b/packages/browser/src/__tests__/featureflags.test.ts index 5ca7524cd1..314e3abcd6 100644 --- a/packages/browser/src/__tests__/featureflags.test.ts +++ b/packages/browser/src/__tests__/featureflags.test.ts @@ -1169,6 +1169,136 @@ describe('featureflags', () => { }) }) + describe('device_id in flags requests', () => { + beforeEach(() => { + // Clear persistence before each test in this suite + instance.persistence.unregister('$device_id') + instance.persistence.unregister('$stored_person_properties') + instance.persistence.unregister('$stored_group_properties') + + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + featureFlags: { + first: 'variant-1', + second: true, + }, + }, + }) + ) + }) + + afterEach(() => { + // Clean up after each test + instance.persistence.unregister('$device_id') + instance.persistence.unregister('$stored_person_properties') + instance.persistence.unregister('$stored_group_properties') + }) + + it('should include device_id in flags request when available', () => { + instance.persistence.register({ + $device_id: 'test-device-uuid-123', + }) + + featureFlags.reloadFeatureFlags() + jest.runAllTimers() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data).toEqual({ + token: 'random fake token', + distinct_id: 'blah id', + $device_id: 'test-device-uuid-123', + person_properties: {}, + }) + }) + + it('should omit device_id when it is null (cookieless mode)', () => { + instance.persistence.register({ + $device_id: null, + }) + + featureFlags.reloadFeatureFlags() + jest.runAllTimers() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data).toEqual({ + token: 'random fake token', + distinct_id: 'blah id', + person_properties: {}, + }) + expect(instance._send_request.mock.calls[0][0].data).not.toHaveProperty('$device_id') + }) + + it('should omit device_id when it is undefined', () => { + // Don't register device_id at all + featureFlags.reloadFeatureFlags() + jest.runAllTimers() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data).toEqual({ + token: 'random fake token', + distinct_id: 'blah id', + person_properties: {}, + }) + expect(instance._send_request.mock.calls[0][0].data).not.toHaveProperty('$device_id') + }) + + it('should include device_id along with $anon_distinct_id on identify', () => { + instance.persistence.register({ + $device_id: 'device-uuid-456', + }) + + featureFlags.setAnonymousDistinctId('anon_id_789') + featureFlags.reloadFeatureFlags() + jest.runAllTimers() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data).toEqual({ + token: 'random fake token', + distinct_id: 'blah id', + $device_id: 'device-uuid-456', + $anon_distinct_id: 'anon_id_789', + person_properties: {}, + }) + }) + + it('should include device_id with person_properties', () => { + instance.persistence.register({ + $device_id: 'device-uuid-999', + }) + + featureFlags.setPersonPropertiesForFlags({ plan: 'pro', beta_tester: true }) + jest.runAllTimers() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data).toEqual({ + token: 'random fake token', + distinct_id: 'blah id', + $device_id: 'device-uuid-999', + person_properties: { plan: 'pro', beta_tester: true }, + }) + }) + + it('should include device_id with group_properties', () => { + instance.persistence.register({ + $device_id: 'device-uuid-888', + }) + + featureFlags.setGroupPropertiesForFlags({ company: { name: 'Acme', seats: 50 } }) + jest.runAllTimers() + + expect(instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request.mock.calls[0][0].data).toEqual({ + token: 'random fake token', + distinct_id: 'blah id', + $device_id: 'device-uuid-888', + person_properties: {}, + group_properties: { company: { name: 'Acme', seats: 50 } }, + }) + }) + }) + describe('reloadFeatureFlags', () => { beforeEach(() => { instance._send_request = jest.fn().mockImplementation(({ callback }) => diff --git a/packages/browser/src/posthog-featureflags.ts b/packages/browser/src/posthog-featureflags.ts index ad36ea816e..cdcf52c2ce 100644 --- a/packages/browser/src/posthog-featureflags.ts +++ b/packages/browser/src/posthog-featureflags.ts @@ -24,7 +24,7 @@ import { FLAG_CALL_REPORTED, } from './constants' -import { isUndefined, isArray } from '@posthog/core' +import { isUndefined, isArray, isNull } from '@posthog/core' import { createLogger } from './utils/logger' import { getTimezone } from './utils/event-utils' @@ -398,6 +398,8 @@ export class PostHogFeatureFlags { return } const token = this._instance.config.token + const deviceId = this._instance.get_property('$device_id') + const data: Record = { token: token, distinct_id: this._instance.get_distinct_id(), @@ -410,6 +412,11 @@ export class PostHogFeatureFlags { group_properties: this._instance.get_property(STORED_GROUP_PROPERTIES_KEY), } + // Add device_id if available (handle cookieless mode where it's null) + if (!isNull(deviceId) && !isUndefined(deviceId)) { + data.$device_id = deviceId + } + if (options?.disableFlags || this._instance.config.advanced_disable_feature_flags) { data.disable_flags = true }