Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/experiment-browser/src/types/exposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export type Exposure = {
* evaluation for the user. Used for system purposes.
*/
metadata?: Record<string, unknown>;
/**
* (Optional) The time the exposure occurred.
*/
time?: number;
};

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import * as FeatureExperiment from '@amplitude/experiment-js-client';
import mutate, { MutationController } from 'dom-mutator';

import { ConsentAwareExposureHandler } from './exposure/consent-aware-exposure-handler';
import { MessageBus } from './message-bus';
import { showPreviewModeModal } from './preview/preview';
import { ConsentAwareStorage } from './storage/consent-aware-storage';
Expand Down Expand Up @@ -104,6 +105,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
status: ConsentStatus.GRANTED,
};
private storage: ConsentAwareStorage;
private consentAwareExposureHandler: ConsentAwareExposureHandler;

constructor(
apiKey: string,
Expand Down Expand Up @@ -134,6 +136,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient {

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

this.consentAwareExposureHandler = new ConsentAwareExposureHandler(
this.consentOptions.status,
);

this.initialFlags.forEach((flag: EvaluationFlag) => {
const { key, variants, metadata = {} } = flag;

Expand Down Expand Up @@ -265,6 +271,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
);
}
this.globalScope.experimentIntegration.type = 'integration';
this.consentAwareExposureHandler.wrapExperimentIntegrationTrack();
this.experimentClient.addPlugin(this.globalScope.experimentIntegration);
this.experimentClient.setUser(user);

Expand Down Expand Up @@ -535,6 +542,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
public setConsentStatus(consentStatus: ConsentStatus) {
this.consentOptions.status = consentStatus;
this.storage.setConsentStatus(consentStatus);
this.consentAwareExposureHandler.setConsentStatus(consentStatus);
}

private async fetchRemoteFlags() {
Expand Down
102 changes: 102 additions & 0 deletions packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { getGlobalScope } from '@amplitude/experiment-core';
import {
ExperimentEvent,
IntegrationPlugin,
} from '@amplitude/experiment-js-client';

import { ConsentStatus } from '../types';

/**
* Consent-aware exposure handler that wraps window.experimentIntegration.track
*/
export class ConsentAwareExposureHandler {
private pendingEvents: ExperimentEvent[] = [];
private consentStatus: ConsentStatus = ConsentStatus.PENDING;
private originalTrack: ((event: ExperimentEvent) => boolean) | null = null;

constructor(initialConsentStatus: ConsentStatus) {
this.consentStatus = initialConsentStatus;
}

/**
* Wrap the experimentIntegration.track method with consent-aware logic
* Prevents nested wrapping by checking if already wrapped
*/
public wrapExperimentIntegrationTrack(): void {
const globalScope = getGlobalScope();
const experimentIntegration =
globalScope?.experimentIntegration as IntegrationPlugin;
if (experimentIntegration?.track) {
if (this.isTrackMethodWrapped(experimentIntegration.track)) {
return;
}

this.originalTrack = experimentIntegration.track.bind(
experimentIntegration,
);
const wrappedTrack = this.createConsentAwareTrack(this.originalTrack);
(wrappedTrack as any).__isConsentAwareWrapped = true;
experimentIntegration.track = wrappedTrack;
}
}

/**
* Check if a track method is already wrapped
*/
private isTrackMethodWrapped(
trackMethod: (event: ExperimentEvent) => boolean,
): boolean {
return (trackMethod as any).__isConsentAwareWrapped === true;
}

/**
* Create a consent-aware wrapper for the track method
*/
private createConsentAwareTrack(
originalTrack: (event: ExperimentEvent) => boolean,
) {
return (event: ExperimentEvent): boolean => {
if (event?.eventProperties) {
event.eventProperties.time = Date.now();
}
try {
if (this.consentStatus === ConsentStatus.PENDING) {
this.pendingEvents.push(event);
return true;
} else if (this.consentStatus === ConsentStatus.GRANTED) {
return originalTrack(event);
}
return false;
} catch (error) {
console.warn('Failed to track event:', error);
return false;
}
};
}

/**
* Set the consent status and handle pending events accordingly
*/
public setConsentStatus(consentStatus: ConsentStatus): void {
const previousStatus = this.consentStatus;
this.consentStatus = consentStatus;

if (previousStatus === ConsentStatus.PENDING) {
if (consentStatus === ConsentStatus.GRANTED) {
for (const event of this.pendingEvents) {
if (this.originalTrack) {
try {
this.originalTrack(event);
} catch (error) {
console.warn('Failed to track pending event:', error);
}
}
}
this.pendingEvents = [];
} else if (consentStatus === ConsentStatus.REJECTED) {
// Delete all pending events
this.pendingEvents = [];
}
}
}
}
2 changes: 1 addition & 1 deletion packages/experiment-tag/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const initialize = (
config: WebExperimentConfig,
): void => {
if (
getGlobalScope()?.experimentConfig.consentOptions.status ===
getGlobalScope()?.experimentConfig?.consentOptions?.status ===
ConsentStatus.REJECTED
) {
return;
Expand Down
1 change: 0 additions & 1 deletion packages/experiment-tag/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { EvaluationCondition } from '@amplitude/experiment-core';
import {
ExperimentConfig,
ExperimentUser,
Variant,
} from '@amplitude/experiment-js-client';
import { ExperimentClient, Variants } from '@amplitude/experiment-js-client';

Expand Down
Loading