posthog-node
Version:
PostHog Node.js integration
1,722 lines (1,614 loc) • 115 kB
text/typescript
// import { PostHog, PostHogOptions } from '../'
// Uncomment below line while developing to not compile code everytime
import { PostHog as PostHog, PostHogOptions } from '../src/posthog-node'
import { matchProperty, InconclusiveMatchError, relativeDateParseForFeatureFlagMatching } from '../src/feature-flags'
import fetch from '../src/fetch'
import { anyDecideCall, anyLocalEvalCall, apiImplementation } from './test-utils'
import { waitForPromises } from 'posthog-core/test/test-utils/test-utils'
jest.mock('../src/fetch')
jest.spyOn(console, 'debug').mockImplementation()
const mockedFetch = jest.mocked(fetch, true)
const posthogImmediateResolveOptions: PostHogOptions = {
fetchRetryCount: 0,
}
describe('local evaluation', () => {
let posthog: PostHog
jest.useFakeTimers()
afterEach(async () => {
// ensure clean shutdown & no test interdependencies
await posthog.shutdown()
})
it('evaluates person properties with undefined property values', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'person-flag',
active: true,
filters: {
groups: [
{
variant: null,
properties: [
{
key: 'latestBuildVersion',
type: 'person',
value: '.+',
operator: 'regex',
},
{
key: 'latestBuildVersionMajor',
type: 'person',
value: '23',
operator: 'gt',
},
{
key: 'latestBuildVersionMinor',
type: 'person',
value: '31',
operator: 'gt',
},
{
key: 'latestBuildVersionPatch',
type: 'person',
value: '0',
operator: 'gt',
},
],
rollout_percentage: 100,
},
],
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('person-flag', 'some-distinct-id', {
personProperties: {
latestBuildVersion: undefined,
latestBuildVersionMajor: undefined,
latestBuildVersionMinor: undefined,
latestBuildVersionPatch: undefined,
} as unknown as Record<string, string>,
})
).toEqual(false)
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
})
it('evaluates person properties', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'person-flag',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
],
rollout_percentage: null,
},
],
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('person-flag', 'some-distinct-id', { personProperties: { region: 'USA' } })
).toEqual(true)
expect(
await posthog.getFeatureFlag('person-flag', 'some-distinct-id', { personProperties: { region: 'Canada' } })
).toEqual(false)
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
})
it('evaluates group properties', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'group-flag',
active: true,
filters: {
aggregation_group_type_index: 0,
groups: [
{
properties: [
{
group_type_index: 0,
key: 'name',
operator: 'exact',
value: ['Project Name 1'],
type: 'group',
},
],
rollout_percentage: 35,
},
],
},
},
],
group_type_mapping: { '0': 'company', '1': 'project' },
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # groups not passed in, hence false
expect(
await posthog.getFeatureFlag('group-flag', 'some-distinct-id', {
groupProperties: { company: { name: 'Project Name 1' } },
})
).toEqual(false)
expect(
await posthog.getFeatureFlag('group-flag', 'some-distinct-2', {
groupProperties: { company: { name: 'Project Name 2' } },
})
).toEqual(false)
// # this is good
expect(
await posthog.getFeatureFlag('group-flag', 'some-distinct-2', {
groups: { company: 'amazon_without_rollout' },
groupProperties: { company: { name: 'Project Name 1' } },
})
).toEqual(true)
// # rollout % not met
expect(
await posthog.getFeatureFlag('group-flag', 'some-distinct-2', {
groups: { company: 'amazon' },
groupProperties: { company: { name: 'Project Name 1' } },
})
).toEqual(false)
// # property mismatch
expect(
await posthog.getFeatureFlag('group-flag', 'some-distinct-2', {
groups: { company: 'amazon_without_rollout' },
groupProperties: { company: { name: 'Project Name 2' } },
})
).toEqual(false)
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// decide not called
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('evaluates group properties and falls back to decide when group_type_mappings not present', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'group-flag',
active: true,
filters: {
aggregation_group_type_index: 0,
groups: [
{
properties: [
{
group_type_index: 0,
key: 'name',
operator: 'exact',
value: ['Project Name 1'],
type: 'group',
},
],
rollout_percentage: 35,
},
],
},
},
],
// "group_type_mapping": {"0": "company", "1": "project"}
}
mockedFetch.mockImplementation(
apiImplementation({ localFlags: flags, decideFlags: { 'group-flag': 'decide-fallback-value' } })
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # group_type_mappings not present, so fallback to `/decide`
expect(
await posthog.getFeatureFlag('group-flag', 'some-distinct-2', {
groupProperties: {
company: { name: 'Project Name 1' },
},
})
).toEqual('decide-fallback-value')
})
it('evaluates flag with complex definition', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'complex-flag',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
{
key: 'name',
operator: 'exact',
value: ['Aloha'],
type: 'person',
},
],
rollout_percentage: undefined,
},
{
properties: [
{
key: 'email',
operator: 'exact',
value: ['a@b.com', 'b@c.com'],
type: 'person',
},
],
rollout_percentage: 30,
},
{
properties: [
{
key: 'doesnt_matter',
operator: 'exact',
value: ['1', '2'],
type: 'person',
},
],
rollout_percentage: 0,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({ localFlags: flags, decideFlags: { 'complex-flag': 'decide-fallback-value' } })
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', {
personProperties: { region: 'USA', name: 'Aloha' },
})
).toEqual(true)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # this distinctIDs hash is < rollout %
expect(
await posthog.getFeatureFlag('complex-flag', 'some-distinct-id_within_rollout?', {
personProperties: { region: 'USA', email: 'a@b.com' },
})
).toEqual(true)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # will fall back on `/decide`, as all properties present for second group, but that group resolves to false
expect(
await posthog.getFeatureFlag('complex-flag', 'some-distinct-id_outside_rollout?', {
personProperties: { region: 'USA', email: 'a@b.com' },
})
).toEqual('decide-fallback-value')
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some-distinct-id_outside_rollout?',
groups: {},
person_properties: {
distinct_id: 'some-distinct-id_outside_rollout?',
region: 'USA',
email: 'a@b.com',
},
group_properties: {},
geoip_disable: true,
flag_keys_to_evaluate: ['complex-flag'],
}),
})
)
mockedFetch.mockClear()
// # same as above
expect(
await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { doesnt_matter: '1' } })
).toEqual('decide-fallback-value')
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some-distinct-id',
groups: {},
person_properties: { distinct_id: 'some-distinct-id', doesnt_matter: '1' },
group_properties: {},
geoip_disable: true,
flag_keys_to_evaluate: ['complex-flag'],
}),
})
)
mockedFetch.mockClear()
expect(
await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { region: 'USA' } })
).toEqual('decide-fallback-value')
expect(mockedFetch).toHaveBeenCalledTimes(1) // TODO: Check this
mockedFetch.mockClear()
// # won't need to fallback when all values are present, and resolves to False
expect(
await posthog.getFeatureFlag('complex-flag', 'some-distinct-id_outside_rollout?', {
personProperties: { region: 'USA', email: 'a@b.com', name: 'X', doesnt_matter: '1' },
})
).toEqual(false)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('falls back to decide', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [{ key: 'id', value: 98, operator: undefined, type: 'cohort' }],
rollout_percentage: 100,
},
],
},
},
{
id: 2,
name: 'Beta Feature',
key: 'beta-feature2',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
],
rollout_percentage: 100,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'alakazam', 'beta-feature2': 'alakazam2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # beta-feature fallbacks to decide because property type is unknown
expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id')).toEqual('alakazam')
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
mockedFetch.mockClear()
// # beta-feature2 fallbacks to decide because region property not given with call
expect(await posthog.getFeatureFlag('beta-feature2', 'some-distinct-id')).toEqual('alakazam2')
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
})
it('dont fall back to decide when local evaluation is set', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [{ key: 'id', value: 98, operator: undefined, type: 'cohort' }],
rollout_percentage: 100,
},
],
},
},
{
id: 2,
name: 'Beta Feature',
key: 'beta-feature2',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
],
rollout_percentage: 100,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'alakazam', 'beta-feature2': 'alakazam2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # beta-feature should fallback to decide because property type is unknown
// # but doesn't because only_evaluate_locally is true
expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual(
undefined
)
expect(await posthog.isFeatureEnabled('beta-feature', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual(
undefined
)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # beta-feature2 should fallback to decide because region property not given with call
// # but doesn't because only_evaluate_locally is true
expect(await posthog.getFeatureFlag('beta-feature2', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual(
undefined
)
expect(await posthog.isFeatureEnabled('beta-feature2', 'some-distinct-id', { onlyEvaluateLocally: true })).toEqual(
undefined
)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it("doesn't return undefined when flag is evaluated successfully", async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags, decideFlags: {} }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # beta-feature resolves to False
expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id')).toEqual(false)
expect(await posthog.isFeatureEnabled('beta-feature', 'some-distinct-id')).toEqual(false)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # beta-feature2 falls back to decide, and whatever decide returns is the value
expect(await posthog.getFeatureFlag('beta-feature2', 'some-distinct-id')).toEqual(undefined)
expect(await posthog.isFeatureEnabled('beta-feature2', 'some-distinct-id')).toEqual(undefined)
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
})
it('experience continuity flags are not evaluated locally', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
ensure_experience_continuity: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({ localFlags: flags, decideFlags: { 'beta-feature': 'decide-fallback-value' } })
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # beta-feature2 falls back to decide, which on error returns default
expect(await posthog.getFeatureFlag('beta-feature', 'some-distinct-id')).toEqual('decide-fallback-value')
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
})
it('get all flags with fallback', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
},
},
{
id: 3,
name: 'Beta Feature',
key: 'beta-feature2',
active: true,
filters: {
groups: [
{
properties: [{ key: 'country', value: 'US' }],
rollout_percentage: 0,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # beta-feature value overridden by /decide
expect(await posthog.getAllFlags('distinct-id')).toEqual({
'beta-feature': 'variant-1',
'beta-feature2': 'variant-2',
'disabled-feature': false,
})
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
mockedFetch.mockClear()
})
it('get all payloads with fallback', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
payloads: {
true: 'some-payload',
},
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
payloads: {
true: 'another-payload',
},
},
},
{
id: 3,
name: 'Beta Feature',
key: 'beta-feature2',
active: true,
filters: {
groups: [
{
properties: [{ key: 'country', value: 'US' }],
rollout_percentage: 0,
},
],
payloads: {
true: 'payload-3',
},
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
decideFlagPayloads: { 'beta-feature': 100, 'beta-feature2': 300 },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # beta-feature value overridden by /decide
expect((await posthog.getAllFlagsAndPayloads('distinct-id')).featureFlagPayloads).toEqual({
'beta-feature': 100,
'beta-feature2': 300,
})
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
mockedFetch.mockClear()
})
it('get all flags with fallback but only_locally_evaluated set', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
},
},
{
id: 3,
name: 'Beta Feature',
key: 'beta-feature2',
active: true,
filters: {
groups: [
{
properties: [{ key: 'country', value: 'US' }],
rollout_percentage: 0,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
// # beta-feature2 has no value
expect(await posthog.getAllFlags('distinct-id', { onlyEvaluateLocally: true })).toEqual({
'beta-feature': true,
'disabled-feature': false,
})
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('get all payloads with fallback but only_evaluate_locally set', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
payloads: {
true: 'some-payload',
},
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
payloads: {
true: 'another-payload',
},
},
},
{
id: 3,
name: 'Beta Feature',
key: 'beta-feature2',
active: true,
filters: {
groups: [
{
properties: [{ key: 'country', value: 'US' }],
rollout_percentage: 0,
},
],
payloads: {
true: 'payload-3',
},
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
decideFlagPayloads: { 'beta-feature': 100, 'beta-feature2': 300 },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
(await posthog.getAllFlagsAndPayloads('distinct-id', { onlyEvaluateLocally: true })).featureFlagPayloads
).toEqual({
'beta-feature': 'some-payload',
})
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('get all flags with fallback, with no local flags', async () => {
const flags = {
flags: [],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(await posthog.getAllFlags('distinct-id')).toEqual({
'beta-feature': 'variant-1',
'beta-feature2': 'variant-2',
})
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
mockedFetch.mockClear()
})
it('get all payloads with fallback, with no local payloads', async () => {
const flags = {
flags: [],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
decideFlagPayloads: { 'beta-feature': 100, 'beta-feature2': 300 },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect((await posthog.getAllFlagsAndPayloads('distinct-id')).featureFlagPayloads).toEqual({
'beta-feature': 100,
'beta-feature2': 300,
})
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
mockedFetch.mockClear()
})
it('get all flags with no fallback', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': true, 'disabled-feature': false })
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('get all payloads with no fallback', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
payloads: {
true: 'new',
},
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
payloads: {
true: 'some-payload',
},
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect((await posthog.getAllFlagsAndPayloads('distinct-id')).featureFlagPayloads).toEqual({ 'beta-feature': 'new' })
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('computes inactive flags locally as well', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: { 'beta-feature': 'variant-1', 'beta-feature2': 'variant-2' },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': true, 'disabled-feature': false })
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # Now, after a poll interval, flag 1 is inactive, and flag 2 rollout is set to 100%.
const flags2 = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: false,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
},
},
{
id: 2,
name: 'Beta Feature',
key: 'disabled-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags2 }))
// # force reload to simulate poll interval
await posthog.reloadFeatureFlags()
expect(await posthog.getAllFlags('distinct-id')).toEqual({ 'beta-feature': false, 'disabled-feature': true })
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('computes complex cohorts locally', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
{ key: 'id', value: 98, type: 'cohort' },
],
rollout_percentage: 100,
},
],
},
},
],
cohorts: {
'98': {
type: 'OR',
values: [
{ key: 'id', value: 1, type: 'cohort' },
{
key: 'nation',
operator: 'exact',
value: ['UK'],
type: 'person',
},
],
},
'1': {
type: 'AND',
values: [{ key: 'other', operator: 'exact', value: ['thing'], type: 'person' }],
},
},
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: {},
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'UK' } })
).toEqual(false)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # even though 'other' property is not present, the cohort should still match since it's an OR condition
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
personProperties: { region: 'USA', nation: 'UK' },
})
).toEqual(true)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # even though 'other' property is not present, the cohort should still match since it's an OR condition
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
personProperties: { region: 'USA', other: 'thing' },
})
).toEqual(true)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('computes complex cohorts with negation locally', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
{ key: 'id', value: 98, type: 'cohort' },
],
rollout_percentage: 100,
},
],
},
},
],
cohorts: {
'98': {
type: 'OR',
values: [
{ key: 'id', value: 1, type: 'cohort' },
{
key: 'nation',
operator: 'exact',
value: ['UK'],
type: 'person',
},
],
},
'1': {
type: 'AND',
values: [{ key: 'other', operator: 'exact', value: ['thing'], type: 'person', negation: true }],
},
},
}
mockedFetch.mockImplementation(
apiImplementation({
localFlags: flags,
decideFlags: {},
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', { personProperties: { region: 'UK' } })
).toEqual(false)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # even though 'other' property is not present, the cohort should still match since it's an OR condition
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
personProperties: { region: 'USA', nation: 'UK' },
})
).toEqual(true)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
// # since 'other' is negated, we return False. Since 'nation' is not present, we can't tell whether the flag should be true or false, so go to decide
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
personProperties: { region: 'USA', other: 'thing' },
})
).toEqual(undefined)
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
mockedFetch.mockClear()
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
personProperties: { region: 'USA', other: 'thing2' },
})
).toEqual(true)
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('gets feature flag with variant overrides', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'email',
operator: 'exact',
value: 'test@posthog.com',
type: 'person',
},
],
rollout_percentage: 100,
variant: 'second-variant',
},
{
rollout_percentage: 50,
variant: 'first-variant',
},
],
multivariate: {
variants: [
{
key: 'first-variant',
name: 'First Variant',
rollout_percentage: 50,
},
{
key: 'second-variant',
name: 'Second Variant',
rollout_percentage: 25,
},
{
key: 'third-variant',
name: 'Third Variant',
rollout_percentage: 25,
},
],
},
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
).toEqual('second-variant')
expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('first-variant')
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// decide not called
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('gets feature flag with clashing variant overrides', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'email',
operator: 'exact',
value: 'test@posthog.com',
type: 'person',
},
],
rollout_percentage: 100,
variant: 'second-variant',
},
// # since second-variant comes first in the list, it will be the one that gets picked
{
properties: [
{
key: 'email',
operator: 'exact',
value: 'test@posthog.com',
type: 'person',
},
],
rollout_percentage: 100,
variant: 'first-variant',
},
{
rollout_percentage: 50,
variant: 'first-variant',
},
],
multivariate: {
variants: [
{
key: 'first-variant',
name: 'First Variant',
rollout_percentage: 50,
},
{
key: 'second-variant',
name: 'Second Variant',
rollout_percentage: 25,
},
{
key: 'third-variant',
name: 'Third Variant',
rollout_percentage: 25,
},
],
},
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
).toEqual('second-variant')
expect(
await posthog.getFeatureFlag('beta-feature', 'example_id', { personProperties: { email: 'test@posthog.com' } })
).toEqual('second-variant')
expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('first-variant')
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// decide not called
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('gets feature flag with invalid variant overrides', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'email',
operator: 'exact',
value: 'test@posthog.com',
type: 'person',
},
],
rollout_percentage: 100,
variant: 'second???',
},
{
rollout_percentage: 50,
variant: 'first???',
},
],
multivariate: {
variants: [
{
key: 'first-variant',
name: 'First Variant',
rollout_percentage: 50,
},
{
key: 'second-variant',
name: 'Second Variant',
rollout_percentage: 25,
},
{
key: 'third-variant',
name: 'Third Variant',
rollout_percentage: 25,
},
],
},
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
).toEqual('third-variant')
expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('second-variant')
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// decide not called
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('gets feature flag with multiple variant overrides', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
rollout_percentage: 100,
// # The override applies even if the first condition matches all and gives everyone their default group
},
{
properties: [
{
key: 'email',
operator: 'exact',
value: 'test@posthog.com',
type: 'person',
},
],
rollout_percentage: 100,
variant: 'second-variant',
},
{
rollout_percentage: 50,
variant: 'third-variant',
},
],
multivariate: {
variants: [
{
key: 'first-variant',
name: 'First Variant',
rollout_percentage: 50,
},
{
key: 'second-variant',
name: 'Second Variant',
rollout_percentage: 25,
},
{
key: 'third-variant',
name: 'Third Variant',
rollout_percentage: 25,
},
],
},
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlag('beta-feature', 'test_id', { personProperties: { email: 'test@posthog.com' } })
).toEqual('second-variant')
expect(await posthog.getFeatureFlag('beta-feature', 'example_id')).toEqual('third-variant')
expect(await posthog.getFeatureFlag('beta-feature', 'another_id')).toEqual('second-variant')
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// decide not called
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('get feature flag payload based on boolean flag', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'person-flag',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
],
rollout_percentage: null,
},
],
payloads: {
true: {
log: 'all',
},
},
},
},
],
}
mockedFetch.mockImplementation(apiImplementation({ localFlags: flags }))
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
...posthogImmediateResolveOptions,
})
expect(
await posthog.getFeatureFlagPayload('person-flag', 'some-distinct-id', true, {
personProperties: { region: 'USA' },
})
).toEqual({
log: 'all',
})
expect(
await posthog.getFeatureFlagPayload('person-flag', 'some-distinct-id', undefined, {
personProperties: { region: 'USA' },
})
).toEqual({
log: 'all',
})
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// decide not called
expect(mockedFetch).not.toHaveBeenCalledWith(...anyDecideCall)
})
it('get feature flag payload on a multivariate', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'email',
operator: 'exact',
value: 'test@posthog.com',
type: 'person',
},
],
rollout_percentage: 100,
variant: 'second-variant',
},
{
rollout_percentage: 50,
variant: 'first-variant',
},
],
multivariate: {
variants: [
{
key: 'first-variant',
name: 'First Variant',
rollout_percentage: 50,
},
{