Fix GrowthBook exposure did mis-attribution across account switches (#11000)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+69
-11
@@ -1,5 +1,6 @@
|
|||||||
import {createContext, useContext, useMemo} from 'react'
|
import {createContext, useContext, useMemo} from 'react'
|
||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
|
import {type Result} from '@growthbook/growthbook-react'
|
||||||
|
|
||||||
import {Logger} from '#/logger'
|
import {Logger} from '#/logger'
|
||||||
import {
|
import {
|
||||||
@@ -169,6 +170,39 @@ export function AnalyticsContext({
|
|||||||
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GrowthBook attribute name for the did. Must match the key used in
|
||||||
|
* `setAttributes` (`#/analytics/features`) and the assignment unit configured
|
||||||
|
* in the GrowthBook dashboard.
|
||||||
|
*/
|
||||||
|
const DID_HASH_ATTRIBUTE = 'did'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the session metadata override for an exposure event so the event's
|
||||||
|
* `did` is sourced from the unit GrowthBook actually bucketed on
|
||||||
|
* (`result.hashValue`) rather than from ambient React session metadata. This
|
||||||
|
* keeps the `did` and the variation in sync, since both then come from the
|
||||||
|
* same evaluation. See APP-2461.
|
||||||
|
*
|
||||||
|
* Only did-bucketed experiments are overridden. Experiments bucketed on
|
||||||
|
* another attribute (e.g. `deviceId`) have a `hashValue` that is not a did, so
|
||||||
|
* we leave their session metadata untouched and let the ambient did stand.
|
||||||
|
*/
|
||||||
|
function sessionMetadataForResult(
|
||||||
|
parentContext: AnalyticsBaseContextType,
|
||||||
|
result: Result<unknown>,
|
||||||
|
): MergeableMetadata | undefined {
|
||||||
|
if (result.hashAttribute !== DID_HASH_ATTRIBUTE) return undefined
|
||||||
|
const {session} = parentContext.metadata
|
||||||
|
if (!session) return undefined
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
...session,
|
||||||
|
did: result.hashValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature gates provider. Decorates the parent analytics context with
|
* Feature gates provider. Decorates the parent analytics context with
|
||||||
* feature gate capabilities. Should be mounted within `AnalyticsContext`,
|
* feature gate capabilities. Should be mounted within `AnalyticsContext`,
|
||||||
@@ -185,22 +219,46 @@ export function AnalyticsFeaturesContext({
|
|||||||
* Side-effects: we need to synchronously set these during the same render
|
* Side-effects: we need to synchronously set these during the same render
|
||||||
* cycle. These calls do not trigger re-renders, they just set properties on
|
* cycle. These calls do not trigger re-renders, they just set properties on
|
||||||
* the singleton GrowthBook instance.
|
* the singleton GrowthBook instance.
|
||||||
|
*
|
||||||
|
* Order matters here. We register the tracking callbacks _before_ calling
|
||||||
|
* `setAttributes`, because `setAttributes` triggers a synchronous
|
||||||
|
* re-evaluation that can fire exposure events. Registering first guarantees
|
||||||
|
* those events run through this render's callback (with this render's
|
||||||
|
* metadata) rather than a stale callback left over from a previous render,
|
||||||
|
* e.g. after an account switch remounts this provider via the
|
||||||
|
* `<Fragment key={did} />` breaker in `App.<platform>.tsx`. See APP-2461.
|
||||||
|
*
|
||||||
|
* We deliberately keep these synchronous rather than moving them into a
|
||||||
|
* `useEffect`: `setAttributes` must run before children evaluate gates (or
|
||||||
|
* they bucket on the previous account's attributes), and the feature usage
|
||||||
|
* callback has no deferred-replay, so a feature evaluated before it is
|
||||||
|
* registered would lose its `feature:viewed` event entirely.
|
||||||
*/
|
*/
|
||||||
setAttributes(parentContext.metadata)
|
|
||||||
feats.setTrackingCallback((experiment, result) => {
|
feats.setTrackingCallback((experiment, result) => {
|
||||||
parentContext.metric('experiment:viewed', {
|
parentContext.metric(
|
||||||
experimentId: experiment.key,
|
'experiment:viewed',
|
||||||
variationId: result.key,
|
{
|
||||||
})
|
experimentId: experiment.key,
|
||||||
|
variationId: result.key,
|
||||||
|
},
|
||||||
|
sessionMetadataForResult(parentContext, result),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
feats.setFeatureUsageCallback((feature, result) => {
|
feats.setFeatureUsageCallback((feature, result) => {
|
||||||
parentContext.metric('feature:viewed', {
|
parentContext.metric(
|
||||||
featureId: feature,
|
'feature:viewed',
|
||||||
featureResultValue: result.value,
|
{
|
||||||
experimentId: result.experiment?.key,
|
featureId: feature,
|
||||||
variationId: result.experimentResult?.key,
|
featureResultValue: result.value,
|
||||||
})
|
experimentId: result.experiment?.key,
|
||||||
|
variationId: result.experimentResult?.key,
|
||||||
|
},
|
||||||
|
result.experimentResult
|
||||||
|
? sessionMetadataForResult(parentContext, result.experimentResult)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
setAttributes(parentContext.metadata)
|
||||||
|
|
||||||
const childContext = useMemo<AnalyticsContextType>(() => {
|
const childContext = useMemo<AnalyticsContextType>(() => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user