diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index d6b1f55c81..b40b722095 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -1,5 +1,6 @@ import {createContext, useContext, useMemo} from 'react' import {Platform} from 'react-native' +import {type Result} from '@growthbook/growthbook-react' import {Logger} from '#/logger' import { @@ -169,6 +170,39 @@ export function AnalyticsContext({ return {children} } +/** + * 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, +): 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 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 * cycle. These calls do not trigger re-renders, they just set properties on * 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 + * `` breaker in `App..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) => { - parentContext.metric('experiment:viewed', { - experimentId: experiment.key, - variationId: result.key, - }) + parentContext.metric( + 'experiment:viewed', + { + experimentId: experiment.key, + variationId: result.key, + }, + sessionMetadataForResult(parentContext, result), + ) }) feats.setFeatureUsageCallback((feature, result) => { - parentContext.metric('feature:viewed', { - featureId: feature, - featureResultValue: result.value, - experimentId: result.experiment?.key, - variationId: result.experimentResult?.key, - }) + parentContext.metric( + 'feature:viewed', + { + featureId: feature, + featureResultValue: result.value, + experimentId: result.experiment?.key, + variationId: result.experimentResult?.key, + }, + result.experimentResult + ? sessionMetadataForResult(parentContext, result.experimentResult) + : undefined, + ) }) + setAttributes(parentContext.metadata) const childContext = useMemo(() => { return {