posthog-node
Version:
PostHog Node.js integration
1,344 lines (1,183 loc) • 42.8 kB
text/typescript
import { MINIMUM_POLLING_INTERVAL, PostHog as PostHog, THIRTY_SECONDS } from '../src/posthog-node'
import fetch from '../src/fetch'
import { anyDecideCall, anyLocalEvalCall, apiImplementation } from './test-utils'
import { waitForPromises, wait } from '../../posthog-core/test/test-utils/test-utils'
import { randomUUID } from 'crypto'
jest.mock('../src/fetch')
jest.mock('../package.json', () => ({ version: '1.2.3' }))
const mockedFetch = jest.mocked(fetch, true)
const waitForFlushTimer = async (): Promise<void> => {
await waitForPromises()
// To trigger the flush via the timer
jest.runOnlyPendingTimers()
// Then wait for the flush promise
await waitForPromises()
}
const getLastBatchEvents = (): any[] | undefined => {
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' }))
// reverse mock calls array to get the last call
const call = mockedFetch.mock.calls.reverse().find((x) => (x[0] as string).includes('/batch/'))
if (!call) {
return undefined
}
return JSON.parse((call[1] as any).body as any).batch
}
describe('PostHog Node.js', () => {
let posthog: PostHog
jest.useFakeTimers()
beforeEach(() => {
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
fetchRetryCount: 0,
})
mockedFetch.mockResolvedValue({
status: 200,
text: () => Promise.resolve('ok'),
json: () =>
Promise.resolve({
status: 'ok',
}),
} as any)
})
afterEach(async () => {
mockedFetch.mockResolvedValue({
status: 200,
text: () => Promise.resolve('ok'),
json: () =>
Promise.resolve({
status: 'ok',
}),
} as any)
// ensure clean shutdown & no test interdependencies
await posthog.shutdown()
})
describe('core methods', () => {
it('should capture an event to shared queue', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 } })
await waitForFlushTimer()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toEqual([
{
distinct_id: '123',
event: 'test-event',
properties: {
$groups: { org: 123 },
foo: 'bar',
$geoip_disable: true,
$lib: 'posthog-node',
$lib_version: '1.2.3',
},
uuid: expect.any(String),
timestamp: expect.any(String),
type: 'capture',
library: 'posthog-node',
library_version: '1.2.3',
},
])
})
it('shouldnt muddy subsequent capture calls', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 } })
await waitForFlushTimer()
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: '123',
event: 'test-event',
properties: expect.objectContaining({
$groups: { org: 123 },
foo: 'bar',
}),
library: 'posthog-node',
library_version: '1.2.3',
})
)
mockedFetch.mockClear()
posthog.capture({
distinctId: '123',
event: 'test-event',
properties: { foo: 'bar' },
groups: { other_group: 'x' },
})
await waitForFlushTimer()
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: '123',
event: 'test-event',
properties: expect.objectContaining({
$groups: { other_group: 'x' },
foo: 'bar',
$geoip_disable: true,
}),
library: 'posthog-node',
library_version: '1.2.3',
})
)
})
it('should capture identify events on shared queue', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.identify({ distinctId: '123', properties: { foo: 'bar' } })
jest.runOnlyPendingTimers()
await waitForPromises()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '123',
event: '$identify',
properties: {
$set: {
foo: 'bar',
},
$geoip_disable: true,
},
},
])
})
it('should handle identify using $set and $set_once', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.identify({ distinctId: '123', properties: { $set: { foo: 'bar' }, $set_once: { vip: true } } })
jest.runOnlyPendingTimers()
await waitForPromises()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '123',
event: '$identify',
properties: {
$set: {
foo: 'bar',
},
$set_once: {
vip: true,
},
$geoip_disable: true,
},
},
])
})
it('should handle identify using $set_once', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.identify({ distinctId: '123', properties: { foo: 'bar', $set_once: { vip: true } } })
jest.runOnlyPendingTimers()
await waitForPromises()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '123',
event: '$identify',
properties: {
$set: {
foo: 'bar',
},
$set_once: {
vip: true,
},
$geoip_disable: true,
},
},
])
})
it('should capture alias events on shared queue', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.alias({ distinctId: '123', alias: '1234' })
jest.runOnlyPendingTimers()
await waitForPromises()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '123',
event: '$create_alias',
properties: {
distinct_id: '123',
alias: '1234',
$geoip_disable: true,
},
},
])
})
it('should allow overriding timestamp', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.capture({ event: 'custom-time', distinctId: '123', timestamp: new Date('2021-02-03') })
await waitForFlushTimer()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '123',
timestamp: '2021-02-03T00:00:00.000Z',
event: 'custom-time',
uuid: expect.any(String),
},
])
})
it('should allow overriding uuid', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
const uuid = randomUUID()
posthog.capture({ event: 'custom-time', distinctId: '123', uuid })
await waitForFlushTimer()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '123',
timestamp: expect.any(String),
event: 'custom-time',
uuid: uuid,
},
])
})
it('should respect disableGeoip setting if passed in', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog.capture({
distinctId: '123',
event: 'test-event',
properties: { foo: 'bar' },
groups: { org: 123 },
disableGeoip: false,
})
await waitForFlushTimer()
const batchEvents = getLastBatchEvents()
expect(batchEvents?.[0].properties).toEqual({
$groups: { org: 123 },
foo: 'bar',
$lib: 'posthog-node',
$lib_version: '1.2.3',
})
})
it('should use default is set, and override on specific disableGeoip calls', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
const client = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
disableGeoip: false,
})
client.capture({ distinctId: '123', event: 'test-event', properties: { foo: 'bar' }, groups: { org: 123 } })
await waitForFlushTimer()
let batchEvents = getLastBatchEvents()
expect(batchEvents?.[0].properties).toEqual({
$groups: { org: 123 },
foo: 'bar',
$lib: 'posthog-node',
$lib_version: '1.2.3',
})
client.capture({
distinctId: '123',
event: 'test-event',
properties: { foo: 'bar' },
groups: { org: 123 },
disableGeoip: true,
})
await waitForFlushTimer()
batchEvents = getLastBatchEvents()
expect(batchEvents?.[0].properties).toEqual({
$groups: { org: 123 },
foo: 'bar',
$lib: 'posthog-node',
$lib_version: '1.2.3',
$geoip_disable: true,
})
client.capture({
distinctId: '123',
event: 'test-event',
properties: { foo: 'bar' },
groups: { org: 123 },
disableGeoip: false,
})
await waitForFlushTimer()
await waitForPromises()
batchEvents = getLastBatchEvents()
expect(batchEvents?.[0].properties).toEqual({
$groups: { org: 123 },
foo: 'bar',
$lib: 'posthog-node',
$lib_version: '1.2.3',
})
await client.shutdown()
})
it('should warn if capture is called with a string', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
posthog.debug(true)
// @ts-expect-error - Testing the warning when passing a string instead of an object
posthog.capture('test-event')
expect(warnSpy).toHaveBeenCalledWith(
'Called capture() with a string as the first argument when an object was expected.'
)
warnSpy.mockRestore()
})
})
describe('shutdown', () => {
let warnSpy: jest.SpyInstance, logSpy: jest.SpyInstance
beforeEach(() => {
const actualLog = console.log
warnSpy = jest.spyOn(console, 'warn').mockImplementation((...args) => {
actualLog('spied warn:', ...args)
})
logSpy = jest.spyOn(console, 'log').mockImplementation((...args) => {
actualLog('spied log:', ...args)
})
mockedFetch.mockImplementation(async () => {
// simulate network delay
await wait(500)
return Promise.resolve({
status: 200,
text: () => Promise.resolve('ok'),
json: () =>
Promise.resolve({
status: 'ok',
}),
} as any)
})
jest.useRealTimers()
})
afterEach(() => {
jest.useFakeTimers()
})
it('should shutdown cleanly', async () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
fetchRetryCount: 0,
flushAt: 1,
})
ph.debug(true)
// using debug mode to check console.log output
// which tells us when the flush is complete
ph.capture({ event: 'test-event', distinctId: '123' })
await wait(100)
expect(logSpy).toHaveBeenCalledTimes(1)
ph.capture({ event: 'test-event', distinctId: '123' })
ph.capture({ event: 'test-event', distinctId: '123' })
await wait(100)
expect(logSpy).toHaveBeenCalledTimes(3)
await wait(400) // The flush will resolve in this time
ph.capture({ event: 'test-event', distinctId: '123' })
ph.capture({ event: 'test-event', distinctId: '123' })
await wait(100)
expect(logSpy).toHaveBeenCalledTimes(6) // 5 captures and 1 flush
expect(5).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('capture')).length)
expect(1).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('flush')).length)
logSpy.mockClear()
expect(logSpy).toHaveBeenCalledTimes(0)
console.warn('YOO!!')
await ph.shutdown()
// 1 final flush for the events that were queued during shutdown
expect(1).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('flush')).length)
logSpy.mockRestore()
warnSpy.mockRestore()
})
it('should shutdown cleanly with pending capture flag promises', async () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
fetchRetryCount: 0,
flushAt: 4,
})
ph.debug(true)
for (let i = 0; i < 10; i++) {
ph.capture({ event: 'test-event', distinctId: `${i}`, sendFeatureFlags: true })
}
await ph.shutdown()
// all capture calls happen during shutdown
const batchEvents = getLastBatchEvents()
expect(batchEvents?.length).toEqual(6)
expect(batchEvents?.[batchEvents?.length - 1]).toMatchObject({
// last event in batch
distinct_id: '9',
event: 'test-event',
library: 'posthog-node',
library_version: '1.2.3',
properties: {
$lib: 'posthog-node',
$lib_version: '1.2.3',
$geoip_disable: true,
},
timestamp: expect.any(String),
type: 'capture',
})
expect(10).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('capture')).length)
// 1 for the captured events, 1 for the final flush of feature flag called events
expect(2).toEqual(logSpy.mock.calls.filter((call) => call[1].includes('flush')).length)
logSpy.mockRestore()
})
})
describe('groupIdentify', () => {
it('should identify group with unique id', async () => {
posthog.groupIdentify({ groupType: 'posthog', groupKey: 'team-1', properties: { analytics: true } })
jest.runOnlyPendingTimers()
await posthog.flush()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '$posthog_team-1',
event: '$groupidentify',
properties: {
$group_type: 'posthog',
$group_key: 'team-1',
$group_set: { analytics: true },
$lib: 'posthog-node',
$geoip_disable: true,
},
},
])
})
it('should allow passing optional distinctID to identify group', async () => {
posthog.groupIdentify({
groupType: 'posthog',
groupKey: 'team-1',
properties: { analytics: true },
distinctId: '123',
})
jest.runOnlyPendingTimers()
await posthog.flush()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: '123',
event: '$groupidentify',
properties: {
$group_type: 'posthog',
$group_key: 'team-1',
$group_set: { analytics: true },
$lib: 'posthog-node',
$geoip_disable: true,
},
},
])
})
})
describe('feature flags', () => {
beforeEach(() => {
const mockFeatureFlags = {
'feature-1': true,
'feature-2': true,
'feature-variant': 'variant',
'disabled-flag': false,
'feature-array': true,
}
// these are stringified in apiImplementation
const mockFeatureFlagPayloads = {
'feature-1': { color: 'blue' },
'feature-variant': 2,
'feature-array': [1],
}
const multivariateFlag = {
id: 1,
name: 'Beta Feature',
key: 'beta-feature-local',
active: true,
rollout_percentage: 100,
filters: {
groups: [
{
properties: [{ key: 'email', type: 'person', value: 'test@posthog.com', operator: 'exact' }],
rollout_percentage: 100,
},
{
rollout_percentage: 50,
},
],
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 },
],
},
payloads: { 'first-variant': 'some-payload', 'third-variant': JSON.stringify({ a: 'json' }) },
},
}
const basicFlag = {
id: 1,
name: 'Beta Feature',
key: 'person-flag',
active: true,
filters: {
groups: [
{
properties: [
{
key: 'region',
operator: 'exact',
value: ['USA'],
type: 'person',
},
],
rollout_percentage: 100,
},
],
payloads: { true: '300' },
},
}
const falseFlag = {
id: 1,
name: 'Beta Feature',
key: 'false-flag',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 0,
},
],
payloads: { true: '300' },
},
}
const arrayFlag = {
id: 5,
name: 'Beta Feature',
key: 'feature-array',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
payloads: { true: JSON.stringify([1]) },
},
}
mockedFetch.mockImplementation(
apiImplementation({
decideFlags: mockFeatureFlags,
decideFlagPayloads: mockFeatureFlagPayloads,
localFlags: { flags: [multivariateFlag, basicFlag, falseFlag, arrayFlag] },
})
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
fetchRetryCount: 0,
})
})
it('should do getFeatureFlag', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(posthog.getFeatureFlag('feature-variant', '123', { groups: { org: '123' } })).resolves.toEqual(
'variant'
)
expect(mockedFetch).toHaveBeenCalledTimes(1)
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') })
)
})
it('should do isFeatureEnabled', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(posthog.isFeatureEnabled('feature-1', '123', { groups: { org: '123' } })).resolves.toEqual(true)
await expect(posthog.isFeatureEnabled('feature-4', '123', { groups: { org: '123' } })).resolves.toEqual(undefined)
expect(mockedFetch).toHaveBeenCalledTimes(2)
})
it('captures feature flags when no personal API key is present', async () => {
mockedFetch.mockClear()
mockedFetch.mockClear()
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
flushAt: 1,
fetchRetryCount: 0,
})
posthog.capture({
distinctId: 'distinct_id',
event: 'node test event',
sendFeatureFlags: true,
})
jest.runOnlyPendingTimers()
await waitForPromises()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST' })
)
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: 'distinct_id',
event: 'node test event',
properties: expect.objectContaining({
$active_feature_flags: ['feature-1', 'feature-2', 'feature-array', 'feature-variant'],
'$feature/feature-1': true,
'$feature/feature-2': true,
'$feature/feature-array': true,
'$feature/feature-variant': 'variant',
$lib: 'posthog-node',
$lib_version: '1.2.3',
$geoip_disable: true,
}),
})
)
// no calls to `/local_evaluation`
expect(mockedFetch).not.toHaveBeenCalledWith(...anyLocalEvalCall)
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') })
)
})
it('should use minimum featureFlagsPollingInterval of 100ms if set less to less than 100', async () => {
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
fetchRetryCount: 0,
personalApiKey: 'TEST_PERSONAL_API_KEY',
featureFlagsPollingInterval: 98,
})
expect(posthog.options.featureFlagsPollingInterval).toEqual(MINIMUM_POLLING_INTERVAL)
})
it('should use default featureFlagsPollingInterval of 30000ms if none provided', async () => {
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
fetchRetryCount: 0,
personalApiKey: 'TEST_PERSONAL_API_KEY',
})
expect(posthog.options.featureFlagsPollingInterval).toEqual(THIRTY_SECONDS)
})
it('should throw an error when creating SDK if a project key is passed in as personalApiKey', async () => {
expect(() => {
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
fetchRetryCount: 0,
personalApiKey: 'phc_abc123',
featureFlagsPollingInterval: 100,
})
}).toThrow(Error)
})
it('captures feature flags with locally evaluated flags', async () => {
mockedFetch.mockClear()
mockedFetch.mockClear()
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
flushAt: 1,
fetchRetryCount: 0,
personalApiKey: 'TEST_PERSONAL_API_KEY',
})
jest.runOnlyPendingTimers()
await waitForPromises()
posthog.capture({
distinctId: 'distinct_id',
event: 'node test event',
})
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// no decide call
expect(mockedFetch).not.toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST' })
)
jest.runOnlyPendingTimers()
await waitForPromises()
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: 'distinct_id',
event: 'node test event',
properties: expect.objectContaining({
$active_feature_flags: ['beta-feature-local', 'feature-array'],
'$feature/beta-feature-local': 'third-variant',
'$feature/feature-array': true,
'$feature/false-flag': false,
$lib: 'posthog-node',
$lib_version: '1.2.3',
$geoip_disable: true,
}),
})
)
expect(
Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature-local')
).toBe(true)
expect(Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature')).toBe(
false
)
await posthog.shutdown()
})
it('doesnt add flag properties when locally evaluated flags are empty', async () => {
mockedFetch.mockClear()
expect(mockedFetch).toHaveBeenCalledTimes(0)
mockedFetch.mockImplementation(
apiImplementation({ decideFlags: { a: false, b: 'true' }, decideFlagPayloads: {}, localFlags: { flags: [] } })
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
flushAt: 1,
fetchRetryCount: 0,
personalApiKey: 'TEST_PERSONAL_API_KEY',
})
posthog.capture({
distinctId: 'distinct_id',
event: 'node test event',
})
jest.runOnlyPendingTimers()
await waitForPromises()
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
// no decide call
expect(mockedFetch).not.toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST' })
)
jest.runOnlyPendingTimers()
await waitForPromises()
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: 'distinct_id',
event: 'node test event',
properties: expect.objectContaining({
$lib: 'posthog-node',
$lib_version: '1.2.3',
$geoip_disable: true,
}),
})
)
expect(
Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature-local')
).toBe(false)
expect(Object.prototype.hasOwnProperty.call(getLastBatchEvents()?.[0].properties, '$feature/beta-feature')).toBe(
false
)
})
it('captures feature flags with same geoip setting as capture', async () => {
mockedFetch.mockClear()
mockedFetch.mockClear()
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
flushAt: 1,
fetchRetryCount: 0,
})
posthog.capture({
distinctId: 'distinct_id',
event: 'node test event',
sendFeatureFlags: true,
disableGeoip: false,
})
await waitForFlushTimer()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST', body: expect.not.stringContaining('geoip_disable') })
)
expect(getLastBatchEvents()?.[0].properties).toEqual({
$active_feature_flags: ['feature-1', 'feature-2', 'feature-array', 'feature-variant'],
'$feature/feature-1': true,
'$feature/feature-2': true,
'$feature/feature-array': true,
'$feature/disabled-flag': false,
'$feature/feature-variant': 'variant',
$lib: 'posthog-node',
$lib_version: '1.2.3',
})
// no calls to `/local_evaluation`
expect(mockedFetch).not.toHaveBeenCalledWith(...anyLocalEvalCall)
})
it('manages memory well when sending feature flags', async () => {
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [],
rollout_percentage: 100,
},
],
},
},
],
}
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',
maxCacheSize: 10,
fetchRetryCount: 0,
flushAt: 1,
})
expect(Object.keys(posthog.distinctIdHasSentFlagCalls).length).toEqual(0)
for (let i = 0; i < 100; i++) {
const distinctId = `some-distinct-id${i}`
await posthog.getFeatureFlag('beta-feature', distinctId)
await waitForPromises()
jest.runOnlyPendingTimers()
const batchEvents = getLastBatchEvents()
expect(batchEvents).toMatchObject([
{
distinct_id: distinctId,
event: '$feature_flag_called',
properties: expect.objectContaining({
$feature_flag: 'beta-feature',
$feature_flag_response: true,
$lib: 'posthog-node',
$lib_version: '1.2.3',
locally_evaluated: true,
'$feature/beta-feature': true,
}),
},
])
mockedFetch.mockClear()
expect(Object.keys(posthog.distinctIdHasSentFlagCalls).length <= 10).toEqual(true)
}
})
it('$feature_flag_called is called appropriately when querying flags', async () => {
mockedFetch.mockClear()
const flags = {
flags: [
{
id: 1,
name: 'Beta Feature',
key: 'beta-feature',
active: true,
filters: {
groups: [
{
properties: [{ key: 'region', value: 'USA' }],
rollout_percentage: 100,
},
],
},
},
],
}
mockedFetch.mockImplementation(
apiImplementation({ localFlags: flags, decideFlags: { 'decide-flag': 'decide-value' } })
)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
personalApiKey: 'TEST_PERSONAL_API_KEY',
maxCacheSize: 10,
fetchRetryCount: 0,
})
jest.runOnlyPendingTimers()
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
personProperties: { region: 'USA', name: 'Aloha' },
})
).toEqual(true)
// TRICKY: There's now an extra step before events are queued, so need to wait for that to resolve
jest.runOnlyPendingTimers()
await waitForPromises()
await posthog.flush()
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object))
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: 'some-distinct-id',
event: '$feature_flag_called',
properties: expect.objectContaining({
$feature_flag: 'beta-feature',
$feature_flag_response: true,
'$feature/beta-feature': true,
$lib: 'posthog-node',
$lib_version: '1.2.3',
locally_evaluated: true,
$geoip_disable: true,
}),
})
)
mockedFetch.mockClear()
// # called again for same user, shouldn't call capture again
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id', {
personProperties: { region: 'USA', name: 'Aloha' },
})
).toEqual(true)
jest.runOnlyPendingTimers()
await waitForPromises()
await posthog.flush()
expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object))
// # called for different user, should call capture again
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id2', {
groups: { x: 'y' },
personProperties: { region: 'USA', name: 'Aloha' },
disableGeoip: false,
})
).toEqual(true)
jest.runOnlyPendingTimers()
await waitForPromises()
await posthog.flush()
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object))
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: 'some-distinct-id2',
event: '$feature_flag_called',
})
)
expect(getLastBatchEvents()?.[0].properties).toEqual({
$feature_flag: 'beta-feature',
$feature_flag_response: true,
$lib: 'posthog-node',
$lib_version: '1.2.3',
locally_evaluated: true,
'$feature/beta-feature': true,
$groups: { x: 'y' },
})
mockedFetch.mockClear()
// # called for different user, but send configuration is false, so should NOT call capture again
expect(
await posthog.getFeatureFlag('beta-feature', 'some-distinct-id23', {
personProperties: { region: 'USA', name: 'Aloha' },
sendFeatureFlagEvents: false,
})
).toEqual(true)
jest.runOnlyPendingTimers()
await waitForPromises()
await posthog.flush()
expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object))
// # called for different flag, falls back to decide, should call capture again
expect(
await posthog.getFeatureFlag('decide-flag', 'some-distinct-id2345', {
groups: { organization: 'org1' },
personProperties: { region: 'USA', name: 'Aloha' },
})
).toEqual('decide-value')
jest.runOnlyPendingTimers()
await waitForPromises()
await posthog.flush()
// one to decide, one to batch
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object))
expect(getLastBatchEvents()?.[0]).toEqual(
expect.objectContaining({
distinct_id: 'some-distinct-id2345',
event: '$feature_flag_called',
properties: expect.objectContaining({
$feature_flag: 'decide-flag',
$feature_flag_response: 'decide-value',
$lib: 'posthog-node',
$lib_version: '1.2.3',
locally_evaluated: false,
'$feature/decide-flag': 'decide-value',
$groups: { organization: 'org1' },
}),
})
)
mockedFetch.mockClear()
expect(
await posthog.isFeatureEnabled('decide-flag', 'some-distinct-id2345', {
groups: { organization: 'org1' },
personProperties: { region: 'USA', name: 'Aloha' },
})
).toEqual(true)
jest.runOnlyPendingTimers()
await waitForPromises()
await posthog.flush()
// call decide, but not batch
expect(mockedFetch).toHaveBeenCalledWith(...anyDecideCall)
expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object))
})
it('should do getFeatureFlagPayloads', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(
posthog.getFeatureFlagPayload('feature-variant', '123', 'variant', { groups: { org: '123' } })
).resolves.toEqual(2)
expect(mockedFetch).toHaveBeenCalledTimes(1)
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') })
)
})
it('should not double parse json with getFeatureFlagPayloads and local eval', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
flushAt: 1,
fetchRetryCount: 0,
personalApiKey: 'TEST_PERSONAL_API_KEY',
})
mockedFetch.mockClear()
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(
posthog.getFeatureFlagPayload('feature-array', '123', true, { onlyEvaluateLocally: true })
).resolves.toEqual([1])
expect(mockedFetch).toHaveBeenCalledTimes(1)
expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall)
mockedFetch.mockClear()
await expect(posthog.getFeatureFlagPayload('feature-array', '123')).resolves.toEqual([1])
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(posthog.getFeatureFlagPayload('false-flag', '123', true)).resolves.toEqual(300)
// Check no non-batch API calls were made
const additionalNonBatchCalls = mockedFetch.mock.calls.filter((call) => !call[0].includes('/batch'))
expect(additionalNonBatchCalls.length).toBe(0)
})
it('should not double parse json with getFeatureFlagPayloads and server eval', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(
posthog.getFeatureFlagPayload('feature-array', '123', undefined, { groups: { org: '123' } })
).resolves.toEqual([1])
expect(mockedFetch).toHaveBeenCalledTimes(1)
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') })
)
})
it('should do getFeatureFlagPayloads without matchValue', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(
posthog.getFeatureFlagPayload('feature-variant', '123', undefined, { groups: { org: '123' } })
).resolves.toEqual(2)
expect(mockedFetch).toHaveBeenCalledTimes(1)
})
it('should do getFeatureFlags with geoip disabled and enabled', async () => {
expect(mockedFetch).toHaveBeenCalledTimes(0)
await expect(
posthog.getFeatureFlagPayload('feature-variant', '123', 'variant', { groups: { org: '123' } })
).resolves.toEqual(2)
expect(mockedFetch).toHaveBeenCalledTimes(1)
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') })
)
mockedFetch.mockClear()
await expect(posthog.isFeatureEnabled('feature-variant', '123', { disableGeoip: false })).resolves.toEqual(true)
expect(mockedFetch).toHaveBeenCalledTimes(1)
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({ method: 'POST', body: expect.not.stringContaining('geoip_disable') })
)
})
it('should add default person & group properties for feature flags', async () => {
await posthog.getFeatureFlag('random_key', 'some_id', {
groups: { company: 'id:5', instance: 'app.posthog.com' },
personProperties: { x1: 'y1' },
groupProperties: { company: { x: 'y' } },
})
jest.runOnlyPendingTimers()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some_id',
groups: { company: 'id:5', instance: 'app.posthog.com' },
person_properties: {
distinct_id: 'some_id',
x1: 'y1',
},
group_properties: {
company: { $group_key: 'id:5', x: 'y' },
instance: { $group_key: 'app.posthog.com' },
},
geoip_disable: true,
flag_keys_to_evaluate: ['random_key'],
}),
})
)
mockedFetch.mockClear()
await posthog.getFeatureFlag('random_key', 'some_id', {
groups: { company: 'id:5', instance: 'app.posthog.com' },
personProperties: { distinct_id: 'override' },
groupProperties: { company: { $group_key: 'group_override' } },
})
jest.runOnlyPendingTimers()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some_id',
groups: { company: 'id:5', instance: 'app.posthog.com' },
person_properties: {
distinct_id: 'override',
},
group_properties: {
company: { $group_key: 'group_override' },
instance: { $group_key: 'app.posthog.com' },
},
geoip_disable: true,
flag_keys_to_evaluate: ['random_key'],
}),
})
)
mockedFetch.mockClear()
// test nones
await posthog.getAllFlagsAndPayloads('some_id', {
groups: undefined,
personProperties: undefined,
groupProperties: undefined,
})
jest.runOnlyPendingTimers()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some_id',
groups: {},
person_properties: {
distinct_id: 'some_id',
},
group_properties: {},
geoip_disable: true,
}),
})
)
mockedFetch.mockClear()
await posthog.getAllFlags('some_id', {
groups: { company: 'id:5' },
personProperties: undefined,
groupProperties: undefined,
})
jest.runOnlyPendingTimers()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some_id',
groups: { company: 'id:5' },
person_properties: {
distinct_id: 'some_id',
},
group_properties: { company: { $group_key: 'id:5' } },
geoip_disable: true,
}),
})
)
mockedFetch.mockClear()
await posthog.getFeatureFlagPayload('random_key', 'some_id', undefined)
jest.runOnlyPendingTimers()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some_id',
groups: {},
person_properties: {
distinct_id: 'some_id',
},
group_properties: {},
geoip_disable: true,
flag_keys_to_evaluate: ['random_key'],
}),
})
)
mockedFetch.mockClear()
await posthog.isFeatureEnabled('random_key', 'some_id')
jest.runOnlyPendingTimers()
expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/decide/?v=4',
expect.objectContaining({
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: 'some_id',
groups: {},
person_properties: {
distinct_id: 'some_id',
},
group_properties: {},
geoip_disable: true,
flag_keys_to_evaluate: ['random_key'],
}),
})
)
})
it('should log error when decide response has errors', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
mockedFetch.mockImplementation(
apiImplementation({
decideFlags: { 'feature-1': true },
decideFlagPayloads: {},
errorsWhileComputingFlags: true,
})
)
await posthog.getFeatureFlag('feature-1', '123')
expect(errorSpy).toHaveBeenCalledWith(
'[FEATURE FLAGS] Error while computing feature flags, some flags may be missing or incorrect. Learn more at https://posthog.com/docs/feature-flags/best-practices'
)
errorSpy.mockRestore()
})
})
})