Skip to content

Commit 3da987e

Browse files
authored
feat: Cookie consent exposure handling (#222)
* add consent-dependent exposure handler * fix import * move exposure handler * fix lint * add test cases * fix: attach timestamp to impression events (#223) * add timestamp tests * simplify comments * wrap integrationplugin * update tests * check track wrapping, finx lint * fix comment * fix init * nit: formatting * simplify * use date.now
1 parent 6674bce commit 3da987e

File tree

7 files changed

+790
-2
lines changed

7 files changed

+790
-2
lines changed

packages/experiment-browser/src/types/exposure.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export type Exposure = {
4545
* evaluation for the user. Used for system purposes.
4646
*/
4747
metadata?: Record<string, unknown>;
48+
/**
49+
* (Optional) The time the exposure occurred.
50+
*/
51+
time?: number;
4852
};
4953

5054
/**

packages/experiment-tag/src/experiment.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import * as FeatureExperiment from '@amplitude/experiment-js-client';
1616
import mutate, { MutationController } from 'dom-mutator';
1717

18+
import { ConsentAwareExposureHandler } from './exposure/consent-aware-exposure-handler';
1819
import { MessageBus } from './message-bus';
1920
import { showPreviewModeModal } from './preview/preview';
2021
import { ConsentAwareStorage } from './storage/consent-aware-storage';
@@ -104,6 +105,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
104105
status: ConsentStatus.GRANTED,
105106
};
106107
private storage: ConsentAwareStorage;
108+
private consentAwareExposureHandler: ConsentAwareExposureHandler;
107109

108110
constructor(
109111
apiKey: string,
@@ -134,6 +136,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
134136

135137
this.storage = new ConsentAwareStorage(this.consentOptions.status);
136138

139+
this.consentAwareExposureHandler = new ConsentAwareExposureHandler(
140+
this.consentOptions.status,
141+
);
142+
137143
this.initialFlags.forEach((flag: EvaluationFlag) => {
138144
const { key, variants, metadata = {} } = flag;
139145

@@ -265,6 +271,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
265271
);
266272
}
267273
this.globalScope.experimentIntegration.type = 'integration';
274+
this.consentAwareExposureHandler.wrapExperimentIntegrationTrack();
268275
this.experimentClient.addPlugin(this.globalScope.experimentIntegration);
269276
this.experimentClient.setUser(user);
270277

@@ -535,6 +542,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
535542
public setConsentStatus(consentStatus: ConsentStatus) {
536543
this.consentOptions.status = consentStatus;
537544
this.storage.setConsentStatus(consentStatus);
545+
this.consentAwareExposureHandler.setConsentStatus(consentStatus);
538546
}
539547

540548
private async fetchRemoteFlags() {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { getGlobalScope } from '@amplitude/experiment-core';
2+
import {
3+
ExperimentEvent,
4+
IntegrationPlugin,
5+
} from '@amplitude/experiment-js-client';
6+
7+
import { ConsentStatus } from '../types';
8+
9+
/**
10+
* Consent-aware exposure handler that wraps window.experimentIntegration.track
11+
*/
12+
export class ConsentAwareExposureHandler {
13+
private pendingEvents: ExperimentEvent[] = [];
14+
private consentStatus: ConsentStatus = ConsentStatus.PENDING;
15+
private originalTrack: ((event: ExperimentEvent) => boolean) | null = null;
16+
17+
constructor(initialConsentStatus: ConsentStatus) {
18+
this.consentStatus = initialConsentStatus;
19+
}
20+
21+
/**
22+
* Wrap the experimentIntegration.track method with consent-aware logic
23+
* Prevents nested wrapping by checking if already wrapped
24+
*/
25+
public wrapExperimentIntegrationTrack(): void {
26+
const globalScope = getGlobalScope();
27+
const experimentIntegration =
28+
globalScope?.experimentIntegration as IntegrationPlugin;
29+
if (experimentIntegration?.track) {
30+
if (this.isTrackMethodWrapped(experimentIntegration.track)) {
31+
return;
32+
}
33+
34+
this.originalTrack = experimentIntegration.track.bind(
35+
experimentIntegration,
36+
);
37+
const wrappedTrack = this.createConsentAwareTrack(this.originalTrack);
38+
(wrappedTrack as any).__isConsentAwareWrapped = true;
39+
experimentIntegration.track = wrappedTrack;
40+
}
41+
}
42+
43+
/**
44+
* Check if a track method is already wrapped
45+
*/
46+
private isTrackMethodWrapped(
47+
trackMethod: (event: ExperimentEvent) => boolean,
48+
): boolean {
49+
return (trackMethod as any).__isConsentAwareWrapped === true;
50+
}
51+
52+
/**
53+
* Create a consent-aware wrapper for the track method
54+
*/
55+
private createConsentAwareTrack(
56+
originalTrack: (event: ExperimentEvent) => boolean,
57+
) {
58+
return (event: ExperimentEvent): boolean => {
59+
if (event?.eventProperties) {
60+
event.eventProperties.time = Date.now();
61+
}
62+
try {
63+
if (this.consentStatus === ConsentStatus.PENDING) {
64+
this.pendingEvents.push(event);
65+
return true;
66+
} else if (this.consentStatus === ConsentStatus.GRANTED) {
67+
return originalTrack(event);
68+
}
69+
return false;
70+
} catch (error) {
71+
console.warn('Failed to track event:', error);
72+
return false;
73+
}
74+
};
75+
}
76+
77+
/**
78+
* Set the consent status and handle pending events accordingly
79+
*/
80+
public setConsentStatus(consentStatus: ConsentStatus): void {
81+
const previousStatus = this.consentStatus;
82+
this.consentStatus = consentStatus;
83+
84+
if (previousStatus === ConsentStatus.PENDING) {
85+
if (consentStatus === ConsentStatus.GRANTED) {
86+
for (const event of this.pendingEvents) {
87+
if (this.originalTrack) {
88+
try {
89+
this.originalTrack(event);
90+
} catch (error) {
91+
console.warn('Failed to track pending event:', error);
92+
}
93+
}
94+
}
95+
this.pendingEvents = [];
96+
} else if (consentStatus === ConsentStatus.REJECTED) {
97+
// Delete all pending events
98+
this.pendingEvents = [];
99+
}
100+
}
101+
}
102+
}

packages/experiment-tag/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const initialize = (
1414
config: WebExperimentConfig,
1515
): void => {
1616
if (
17-
getGlobalScope()?.experimentConfig.consentOptions.status ===
17+
getGlobalScope()?.experimentConfig?.consentOptions?.status ===
1818
ConsentStatus.REJECTED
1919
) {
2020
return;

packages/experiment-tag/src/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { EvaluationCondition } from '@amplitude/experiment-core';
22
import {
33
ExperimentConfig,
44
ExperimentUser,
5-
Variant,
65
} from '@amplitude/experiment-js-client';
76
import { ExperimentClient, Variants } from '@amplitude/experiment-js-client';
87

0 commit comments

Comments
 (0)