Skip to content

Commit e1617d9

Browse files
authored
feat(flags): add $feature_flag_evaluated_at properties to $feature_flag_called events (#2603)
* JS tings * include changeset * fix * update tests * fix tests * fix mock
1 parent d10783f commit e1617d9

10 files changed

Lines changed: 99 additions & 3 deletions

File tree

.changeset/shiny-wolves-press.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'posthog-js': minor
3+
'@posthog/core': minor
4+
'posthog-node': minor
5+
---
6+
7+
add $feature_flag_evaluated_at properties to $feature_flag_called events

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

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,8 +1704,9 @@ describe('featureflags', () => {
17041704
})
17051705
})
17061706

1707-
describe('Feature Flag Request ID', () => {
1707+
describe('Feature Flag Request ID and Evaluated At', () => {
17081708
const TEST_REQUEST_ID = 'test-request-id-123'
1709+
const TEST_EVALUATED_AT = 1234567890
17091710

17101711
it('saves requestId from /flags response', () => {
17111712
featureFlags.receivedFeatureFlags({
@@ -1717,6 +1718,16 @@ describe('featureflags', () => {
17171718
expect(instance.get_property('$feature_flag_request_id')).toEqual(TEST_REQUEST_ID)
17181719
})
17191720

1721+
it('saves evaluatedAt from /flags response', () => {
1722+
featureFlags.receivedFeatureFlags({
1723+
featureFlags: { 'test-flag': true },
1724+
featureFlagPayloads: {},
1725+
evaluatedAt: TEST_EVALUATED_AT,
1726+
})
1727+
1728+
expect(instance.get_property('$feature_flag_evaluated_at')).toEqual(TEST_EVALUATED_AT)
1729+
})
1730+
17201731
it('includes requestId in feature flag called event', () => {
17211732
// Setup flags with requestId
17221733
featureFlags.receivedFeatureFlags({
@@ -1740,12 +1751,36 @@ describe('featureflags', () => {
17401751
)
17411752
})
17421753

1754+
it('includes evaluatedAt in feature flag called event', () => {
1755+
// Setup flags with evaluatedAt
1756+
featureFlags.receivedFeatureFlags({
1757+
featureFlags: { 'test-flag': true },
1758+
featureFlagPayloads: {},
1759+
evaluatedAt: TEST_EVALUATED_AT,
1760+
})
1761+
featureFlags._hasLoadedFlags = true
1762+
1763+
// Test flag call
1764+
featureFlags.getFeatureFlag('test-flag')
1765+
1766+
// Verify capture call includes evaluatedAt
1767+
expect(instance.capture).toHaveBeenCalledWith(
1768+
'$feature_flag_called',
1769+
expect.objectContaining({
1770+
$feature_flag: 'test-flag',
1771+
$feature_flag_response: true,
1772+
$feature_flag_evaluated_at: TEST_EVALUATED_AT,
1773+
})
1774+
)
1775+
})
1776+
17431777
it('includes version in feature flag called event', () => {
1744-
// Setup flags with requestId
1778+
// Setup flags with requestId and evaluatedAt
17451779
featureFlags.receivedFeatureFlags({
17461780
featureFlags: { 'test-flag': true },
17471781
featureFlagPayloads: {},
17481782
requestId: TEST_REQUEST_ID,
1783+
evaluatedAt: TEST_EVALUATED_AT,
17491784
flags: {
17501785
'test-flag': {
17511786
key: 'test-flag',
@@ -1769,13 +1804,14 @@ describe('featureflags', () => {
17691804
// Test flag call
17701805
featureFlags.getFeatureFlag('test-flag')
17711806

1772-
// Verify capture call includes requestId
1807+
// Verify capture call includes requestId and evaluatedAt
17731808
expect(instance.capture).toHaveBeenCalledWith(
17741809
'$feature_flag_called',
17751810
expect.objectContaining({
17761811
$feature_flag: 'test-flag',
17771812
$feature_flag_response: 'variant-1',
17781813
$feature_flag_request_id: TEST_REQUEST_ID,
1814+
$feature_flag_evaluated_at: TEST_EVALUATED_AT,
17791815
$feature_flag_version: 42,
17801816
$feature_flag_reason: 'Matched condition set 1',
17811817
$feature_flag_id: 23,
@@ -1814,6 +1850,38 @@ describe('featureflags', () => {
18141850
})
18151851
)
18161852
})
1853+
1854+
it('updates evaluatedAt when new /flags response is received', () => {
1855+
// First /flags response
1856+
featureFlags.receivedFeatureFlags({
1857+
featureFlags: { 'test-flag': true },
1858+
featureFlagPayloads: {},
1859+
evaluatedAt: TEST_EVALUATED_AT,
1860+
})
1861+
1862+
expect(instance.get_property('$feature_flag_evaluated_at')).toEqual(TEST_EVALUATED_AT)
1863+
1864+
// Second /flags response with new timestamp
1865+
const NEW_EVALUATED_AT = 9876543210
1866+
featureFlags.receivedFeatureFlags({
1867+
featureFlags: { 'test-flag': true },
1868+
featureFlagPayloads: {},
1869+
evaluatedAt: NEW_EVALUATED_AT,
1870+
})
1871+
1872+
expect(instance.get_property('$feature_flag_evaluated_at')).toEqual(NEW_EVALUATED_AT)
1873+
1874+
// Verify new timestamp is used in events
1875+
featureFlags._hasLoadedFlags = true
1876+
featureFlags.getFeatureFlag('test-flag')
1877+
1878+
expect(instance.capture).toHaveBeenCalledWith(
1879+
'$feature_flag_called',
1880+
expect.objectContaining({
1881+
$feature_flag_evaluated_at: NEW_EVALUATED_AT,
1882+
})
1883+
)
1884+
})
18171885
})
18181886
})
18191887

packages/browser/src/posthog-featureflags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const PERSISTENCE_OVERRIDE_FEATURE_FLAGS = '$override_feature_flags'
3636
const PERSISTENCE_FEATURE_FLAG_PAYLOADS = '$feature_flag_payloads'
3737
const PERSISTENCE_OVERRIDE_FEATURE_FLAG_PAYLOADS = '$override_feature_flag_payloads'
3838
const PERSISTENCE_FEATURE_FLAG_REQUEST_ID = '$feature_flag_request_id'
39+
const PERSISTENCE_FEATURE_FLAG_EVALUATED_AT = '$feature_flag_evaluated_at'
3940

4041
export const filterActiveFeatureFlags = (featureFlags?: Record<string, string | boolean>) => {
4142
const activeFeatureFlags: Record<string, string | boolean> = {}
@@ -64,6 +65,7 @@ export const parseFlagsResponse = (
6465
}
6566

6667
const requestId = response['requestId']
68+
const evaluatedAt = response['evaluatedAt']
6769

6870
// using the v1 api
6971
if (isArray(featureFlags)) {
@@ -100,6 +102,7 @@ export const parseFlagsResponse = (
100102
[PERSISTENCE_FEATURE_FLAG_PAYLOADS]: newFeatureFlagPayloads || {},
101103
[PERSISTENCE_FEATURE_FLAG_DETAILS]: newFeatureFlagDetails || {},
102104
...(requestId ? { [PERSISTENCE_FEATURE_FLAG_REQUEST_ID]: requestId } : {}),
105+
...(evaluatedAt ? { [PERSISTENCE_FEATURE_FLAG_EVALUATED_AT]: evaluatedAt } : {}),
103106
})
104107
}
105108

@@ -506,6 +509,7 @@ export class PostHogFeatureFlags {
506509
const flagValue = this.getFlagVariants()[key]
507510
const flagReportValue = `${flagValue}`
508511
const requestId = this._instance.get_property(PERSISTENCE_FEATURE_FLAG_REQUEST_ID) || undefined
512+
const evaluatedAt = this._instance.get_property(PERSISTENCE_FEATURE_FLAG_EVALUATED_AT) || undefined
509513
const flagCallReported: Record<string, string[]> = this._instance.get_property(FLAG_CALL_REPORTED) || {}
510514

511515
if (options.send_event || !('send_event' in options)) {
@@ -524,6 +528,7 @@ export class PostHogFeatureFlags {
524528
$feature_flag_response: flagValue,
525529
$feature_flag_payload: this.getFeatureFlagPayload(key) || null,
526530
$feature_flag_request_id: requestId,
531+
$feature_flag_evaluated_at: evaluatedAt,
527532
$feature_flag_bootstrapped_response: this._instance.config.bootstrap?.featureFlags?.[key] || null,
528533
$feature_flag_bootstrapped_payload:
529534
this._instance.config.bootstrap?.featureFlagPayloads?.[key] || null,

packages/browser/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,6 +1668,7 @@ export interface FlagsResponse extends RemoteConfig {
16681668
errorsWhileComputingFlags: boolean
16691669
requestId?: string
16701670
flags: Record<string, FeatureFlagDetail>
1671+
evaluatedAt?: number
16711672
}
16721673

16731674
export type SiteAppGlobals = {

packages/core/src/__tests__/posthog.featureflags.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ describe('PostHog Feature Flags v4', () => {
109109
Promise.resolve({
110110
flags: createMockFeatureFlags(),
111111
requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
112+
evaluatedAt: 1640995200000,
112113
}),
113114
})
114115
}
@@ -589,6 +590,7 @@ describe('PostHog Feature Flags v4', () => {
589590
'$feature/feature-1': true,
590591
$used_bootstrap_value: false,
591592
$feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
593+
$feature_flag_evaluated_at: expect.any(Number),
592594
},
593595
type: 'capture',
594596
},
@@ -617,6 +619,7 @@ describe('PostHog Feature Flags v4', () => {
617619
'$feature/feature-1': true,
618620
$used_bootstrap_value: false,
619621
$feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
622+
$feature_flag_evaluated_at: expect.any(Number),
620623
},
621624
type: 'capture',
622625
},
@@ -676,6 +679,7 @@ describe('PostHog Feature Flags v4', () => {
676679
const expectedFeatureFlags = {
677680
flags: createMockFeatureFlags(),
678681
requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
682+
evaluatedAt: 1640995200000,
679683
}
680684
const normalizedFeatureFlags = normalizeFlagsResponse(expectedFeatureFlags as PostHogV2FlagsResponse)
681685
expect(posthog.getPersistedProperty(PostHogPersistedProperty.FeatureFlagDetails)).toEqual(

packages/core/src/posthog-core-stateless.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ export abstract class PostHogCoreStateless {
544544
| {
545545
response: FeatureFlagDetail | undefined
546546
requestId: string | undefined
547+
evaluatedAt: number | undefined
547548
}
548549
| undefined
549550
> {
@@ -569,6 +570,7 @@ export abstract class PostHogCoreStateless {
569570
return {
570571
response: flagDetail,
571572
requestId: flagsResponse.requestId,
573+
evaluatedAt: flagsResponse.evaluatedAt,
572574
}
573575
}
574576

packages/core/src/posthog-core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
726726
// If we haven't yet received a response from the /flags endpoint, we must have used the bootstrapped value
727727
$used_bootstrap_value: !this.getPersistedProperty(PostHogPersistedProperty.FlagsEndpointWasHit),
728728
...maybeAdd('$feature_flag_request_id', details.requestId),
729+
...maybeAdd('$feature_flag_evaluated_at', details.evaluatedAt),
729730
})
730731
}
731732

packages/core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export type PostHogFlagsResponse = Omit<PostHogRemoteConfig, 'hasFeatureFlags'>
191191
}
192192
quotaLimited?: string[]
193193
requestId?: string
194+
evaluatedAt?: number // Unix timestamp in milliseconds
194195
}
195196

196197
export type PostHogFeatureFlagsResponse = PartialWithRequired<

packages/node/src/__tests__/feature-flags.flags.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('flags v2', () => {
1818
flags: {},
1919
errorsWhileComputingFlags: false,
2020
requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
21+
evaluatedAt: 1640995200000,
2122
}
2223
mockedFetch.mockImplementation(apiImplementationV4(flagsResponse))
2324

@@ -46,6 +47,7 @@ describe('flags v2', () => {
4647
$feature_flag: 'non-existent-flag',
4748
$feature_flag_response: undefined,
4849
$feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
50+
$feature_flag_evaluated_at: expect.any(Number),
4951
$groups: undefined,
5052
$lib: posthog.getLibraryId(),
5153
$lib_version: posthog.getLibraryVersion(),
@@ -132,6 +134,7 @@ describe('flags v2', () => {
132134
},
133135
errorsWhileComputingFlags: false,
134136
requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
137+
evaluatedAt: 1640995200000,
135138
}
136139
mockedFetch.mockImplementation(apiImplementationV4(flagsResponse))
137140

@@ -163,6 +166,7 @@ describe('flags v2', () => {
163166
$feature_flag_version: expectedVersion,
164167
$feature_flag_reason: expectedReason,
165168
$feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
169+
$feature_flag_evaluated_at: expect.any(Number),
166170
$groups: undefined,
167171
$lib: posthog.getLibraryId(),
168172
$lib_version: posthog.getLibraryVersion(),

packages/node/src/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
683683

684684
const flagWasLocallyEvaluated = response !== undefined
685685
let requestId = undefined
686+
let evaluatedAt = undefined
686687
let flagDetail: FeatureFlagDetail | undefined = undefined
687688
if (!flagWasLocallyEvaluated && !onlyEvaluateLocally) {
688689
const remoteResponse = await super.getFeatureFlagDetailStateless(
@@ -701,6 +702,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
701702
flagDetail = remoteResponse.response
702703
response = getFeatureFlagValue(flagDetail)
703704
requestId = remoteResponse?.requestId
705+
evaluatedAt = remoteResponse?.evaluatedAt
704706
}
705707

706708
const featureFlagReportedKey = `${key}_${response}`
@@ -730,6 +732,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
730732
locally_evaluated: flagWasLocallyEvaluated,
731733
[`$feature/${key}`]: response,
732734
$feature_flag_request_id: requestId,
735+
$feature_flag_evaluated_at: evaluatedAt,
733736
},
734737
groups,
735738
disableGeoip,

0 commit comments

Comments
 (0)