Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ export class BehavioralTargetingManager {
Array<{ id: string; flagKey: string }>
> = new Map();

constructor(apiKey: string, initialRules: BehavioralTargetingRules = {}) {
constructor(
apiKey: string,
initialRules: BehavioralTargetingRules = {},
sessionTimeoutMs?: number,
) {
this.rules = initialRules;
this.sessionManager = new SessionManager(apiKey);
this.sessionManager = new SessionManager(apiKey, sessionTimeoutMs);
// Build event-to-behavior mapping for efficient lookups
this.buildEventToBehaviorsMap();
this.eventStorage = new EventStorageManager(
Expand All @@ -46,6 +50,10 @@ export class BehavioralTargetingManager {
eventType: string,
properties: Record<string, unknown>,
): void {
// Any observed Amplitude event keeps the session alive (extends or
// rotates it). This runs before the persistedEvents allowlist filter in
// addEvent so that non-RTBT events still count as session activity.
this.sessionManager.recordActivity();
this.eventStorage.addEvent(eventType, properties);
// Update active behavior state for flags affected by this event
this.evaluateEvent(eventType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class EventStorageManager {
return;
}

const sessionId = this.sessionManager.getOrCreateSessionId();
const sessionId = this.sessionManager.getCurrentSessionId();

const event: EventRecord = {
id: this.memoryCache.nextId++,
Expand Down Expand Up @@ -116,7 +116,7 @@ export class EventStorageManager {
);

if (timeType === 'current_session') {
const currentSessionId = this.sessionManager.getOrCreateSessionId();
const currentSessionId = this.sessionManager.getCurrentSessionId();
events = events.filter((e) => e.session_id === currentSessionId);
} else {
// Rolling time window
Expand Down
247 changes: 195 additions & 52 deletions packages/experiment-tag/src/behavioral-targeting/session-manager.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,222 @@
import { getTopLevelDomainSync } from '../util/cookie';

/**
* Manages browser session ID using sessionStorage.
* Session ID is unique per browser tab and cleared when tab closes.
* Default rolling inactivity window before a session rotates, mirroring
* Amplitude Analytics' 30-minute session timeout.
*/
export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000;

interface SessionState {
sessionId: string;
sessionStartTime: number;
/** Epoch ms of the most recent activity; drives the rolling timeout. */
lastEventTime: number;
}

/**
* Manages the RTBT session ID.
*
* Unlike the previous per-tab `sessionStorage` implementation, the session is
* persisted in a root-domain cookie so it is shared across subdomains, and it
* rotates on a rolling inactivity timeout (the same model Amplitude Analytics
* uses). When cookies are unavailable (private browsing, ITP, blocked I/O) the
* session degrades gracefully to an in-memory, per-page session.
*
* Two-tier storage: cookie (authoritative, cross-subdomain + cross-tab) →
* in-memory (per-page fallback / cache).
*/
export class SessionManager {
private storageKey: string;
private sessionId?: string;
private sessionStartTime?: number;
private cookieKey: string;
private sessionTimeoutMs: number;
/** In-memory cache and fallback when cookie storage is unavailable. */
private memoryState?: SessionState;
/** Lazily resolved leading-dot domain (e.g. `.example.com`) or `''`. */
private resolvedDomain?: string;
/** Tri-state: undefined = unknown, true/false = detected via write-back. */
private cookiesUsable?: boolean;

constructor(apiKey: string) {
this.storageKey = `EXP_${apiKey.slice(0, 10)}_rtbt_session`;
constructor(
apiKey: string,
sessionTimeoutMs: number = DEFAULT_SESSION_TIMEOUT_MS,
) {
this.cookieKey = `EXP_${apiKey.slice(0, 10)}_rtbt_session`;
this.sessionTimeoutMs = sessionTimeoutMs;
}

/**
* Gets the current session ID, creating one if it doesn't exist.
* Uses sessionStorage (cleared when tab closes).
* Returns the current session ID, rotating it when the inactivity timeout
* has elapsed and creating one when none exists. This is a read: it does not
* extend the session (use {@link recordActivity} for that).
*/
getOrCreateSessionId(): string {
if (this.sessionId) {
return this.sessionId;
}
getCurrentSessionId(): string {
return this.resolve().sessionId;
}

// Try to load from sessionStorage
const stored = sessionStorage.getItem(this.storageKey);
/**
* Backwards-compatible alias for {@link getCurrentSessionId}.
*/
getOrCreateSessionId(): string {
return this.getCurrentSessionId();
}

if (stored) {
try {
const data = JSON.parse(stored);
if (data.sessionId && typeof data.sessionId === 'string') {
this.sessionId = data.sessionId;
this.sessionStartTime = data.sessionStartTime;
return data.sessionId;
}
} catch (e) {
// Invalid JSON, create new session
}
/**
* Records activity for the current page event: extends the active session
* (bumping `lastEventTime`) or rotates to a new session when the inactivity
* timeout has elapsed. Call this for every observed Amplitude event so that
* any activity keeps the session alive.
*/
recordActivity(): void {
const now = Date.now();
const existing = this.read();
if (existing && now - existing.lastEventTime <= this.sessionTimeoutMs) {
this.persist({ ...existing, lastEventTime: now });
} else {
this.persist(this.newSession(now));
}

// Create new session
this.sessionId = this.generateSessionId();
this.sessionStartTime = Date.now();

sessionStorage.setItem(
this.storageKey,
JSON.stringify({
sessionId: this.sessionId,
sessionStartTime: this.sessionStartTime,
}),
);

return this.sessionId;
}

/**
* Gets the session start time in milliseconds since epoch.
*/
getSessionStartTime(): number | undefined {
if (!this.sessionStartTime) {
this.getOrCreateSessionId(); // Load from storage
}
return this.sessionStartTime;
return this.resolve().sessionStartTime;
}

/**
* Generates a unique session ID.
* Clears the current session from memory and cookie storage.
*/
private generateSessionId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
clearSession(): void {
this.memoryState = undefined;
this.deleteCookie();
}

/**
* Clears the current session (for testing).
* Resolves the active session: returns the persisted session when it is
* still within the inactivity window, otherwise creates, persists and
* returns a fresh one. Does not extend an already-active session.
*/
clearSession(): void {
this.sessionId = undefined;
this.sessionStartTime = undefined;
sessionStorage.removeItem(this.storageKey);
private resolve(): SessionState {
const now = Date.now();
const existing = this.read();
if (existing && now - existing.lastEventTime <= this.sessionTimeoutMs) {
return existing;
}
const fresh = this.newSession(now);
this.persist(fresh);
return fresh;
}

private newSession(now: number): SessionState {
return {
sessionId: `${now}-${Math.random().toString(36).substr(2, 9)}`,
sessionStartTime: now,
lastEventTime: now,
};
}

/**
* Reads session state, preferring the cookie (cross-tab / cross-subdomain
* source of truth) and falling back to the in-memory copy.
*/
private read(): SessionState | undefined {
if (this.cookiesUsable !== false) {
const raw = this.readCookie();
const parsed = raw !== undefined ? this.parse(raw) : undefined;
if (parsed) {
return parsed;
}
}
return this.memoryState;
}

private persist(state: SessionState): void {
this.memoryState = state;
this.writeCookie(state);
}

private parse(raw: string): SessionState | undefined {
try {
const data = JSON.parse(raw);
if (
data &&
typeof data.sessionId === 'string' &&
typeof data.lastEventTime === 'number'
) {
return {
sessionId: data.sessionId,
sessionStartTime:
typeof data.sessionStartTime === 'number'
? data.sessionStartTime
: data.lastEventTime,
lastEventTime: data.lastEventTime,
};
}
} catch {
// Invalid JSON; treat as no session.
}
return undefined;
}

private domain(): string {
if (this.resolvedDomain === undefined) {
this.resolvedDomain =
typeof location !== 'undefined' && location.hostname
? getTopLevelDomainSync(location.hostname)
: '';
}
return this.resolvedDomain;
}

private readCookie(): string | undefined {
if (typeof document === 'undefined') return undefined;
const cookies = document.cookie ? document.cookie.split('; ') : [];
for (const cookie of cookies) {
const eq = cookie.indexOf('=');
const key = eq === -1 ? cookie : cookie.slice(0, eq);
if (key === this.cookieKey) {
const value = eq === -1 ? '' : cookie.slice(eq + 1);
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
}
return undefined;
}

private writeCookie(state: SessionState): void {
if (this.cookiesUsable === false || typeof document === 'undefined') {
this.cookiesUsable = false;
return;
}
try {
const value = encodeURIComponent(JSON.stringify(state));
const domain = this.domain();
const secure =
typeof location !== 'undefined' && location.protocol === 'https:'
? '; Secure'
: '';
document.cookie =
`${this.cookieKey}=${value}; path=/; SameSite=Lax` +
(domain ? `; domain=${domain}` : '') +
secure;
// Verify via read-back: detects blocked cookie I/O (ITP, private mode).
this.cookiesUsable = this.readCookie() !== undefined;
} catch {
this.cookiesUsable = false;
}
}

private deleteCookie(): void {
if (typeof document === 'undefined') return;
try {
const domain = this.domain();
document.cookie =
`${this.cookieKey}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT` +
(domain ? `; domain=${domain}` : '');
} catch {
// Best-effort delete; in-memory state is already cleared.
}
}
}
14 changes: 8 additions & 6 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,19 +168,21 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
? JSON.parse(initConfigs.behavioralTargetingRules)
: {};

// merge config with defaults and experimentConfig (if provided)
this.config = {
...Defaults,
...config,
...(this.globalScope.experimentConfig ?? {}),
};

// Initialize behavioral targeting infrastructure only if there are rules
if (Object.keys(this.behavioralTargetingRules).length > 0) {
this.behavioralTargetingManager = new BehavioralTargetingManager(
this.apiKey,
this.behavioralTargetingRules,
this.config.rtbtSessionTimeout,
);
}
// merge config with defaults and experimentConfig (if provided)
this.config = {
...Defaults,
...config,
...(this.globalScope.experimentConfig ?? {}),
};

this.initialFlags.forEach((flag: EvaluationFlag) => {
const { key, variants, metadata = {} } = flag;
Expand Down
6 changes: 6 additions & 0 deletions packages/experiment-tag/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ export interface WebExperimentConfig extends ExperimentConfig {
*/
useDefaultNavigationHandler?: boolean;
redirectConfig?: RedirectConfig;
/**
* Rolling inactivity timeout, in milliseconds, after which the behavioral
* targeting (RTBT) session rotates. Mirrors Amplitude Analytics' session
* model. Defaults to 30 minutes when unset.
*/
rtbtSessionTimeout?: number;
}

export const Defaults: WebExperimentConfig = {
Expand Down
Loading
Loading