@datalyr/react-native
Version:
Datalyr SDK for React Native & Expo - Server-side attribution tracking
776 lines (662 loc) • 22.6 kB
text/typescript
import { Platform, AppState } from 'react-native';
import {
DatalyrConfig,
EventData,
UserProperties,
EventPayload,
SDKState,
AppState as AppStateType,
AutoEventConfig,
} from './types';
import {
getOrCreateVisitorId,
getOrCreateAnonymousId,
getOrCreateSessionId,
createFingerprintData,
generateUUID,
getDeviceInfo,
getNetworkType,
validateEventName,
validateEventData,
debugLog,
errorLog,
Storage,
STORAGE_KEYS,
} from './utils';
import { createHttpClient, HttpClient } from './http-client';
import { createEventQueue, EventQueue } from './event-queue';
import { attributionManager, AttributionData } from './attribution';
import { createAutoEventsManager, AutoEventsManager, SessionData } from './auto-events';
import { ConversionValueEncoder, ConversionTemplates } from './ConversionValueEncoder';
import { SKAdNetworkBridge } from './native/SKAdNetworkBridge';
export class DatalyrSDK {
private state: SDKState;
private httpClient: HttpClient;
private eventQueue: EventQueue;
private autoEventsManager: AutoEventsManager | null = null;
private appStateSubscription: any = null;
private static conversionEncoder?: ConversionValueEncoder;
private static debugEnabled = false;
constructor() {
// Initialize state with defaults
this.state = {
initialized: false,
config: {
workspaceId: '',
apiKey: '',
debug: false,
endpoint: 'https://api.datalyr.com', // Updated to server-side API
useServerTracking: true, // Default to server-side
maxRetries: 3,
retryDelay: 1000,
batchSize: 10,
flushInterval: 10000,
maxQueueSize: 100,
respectDoNotTrack: true,
},
visitorId: '',
anonymousId: '', // Persistent anonymous identifier
sessionId: '',
userProperties: {},
eventQueue: [],
isOnline: true,
};
// Initialize HTTP client and event queue (will be properly set up in initialize)
this.httpClient = createHttpClient(this.state.config.endpoint!);
this.eventQueue = createEventQueue(this.httpClient);
}
/**
* Initialize the SDK with configuration
*/
async initialize(config: DatalyrConfig): Promise<void> {
try {
debugLog('Initializing Datalyr SDK...', { workspaceId: config.workspaceId });
// Validate configuration
if (!config.apiKey) {
throw new Error('apiKey is required for Datalyr SDK v1.0.0');
}
// workspaceId is now optional (for backward compatibility)
if (!config.workspaceId) {
debugLog('workspaceId not provided, using server-side tracking only');
}
// Set up configuration
this.state.config = { ...this.state.config, ...config };
// Initialize HTTP client with server-side API
this.httpClient = new HttpClient(this.state.config.endpoint || 'https://api.datalyr.com', {
maxRetries: this.state.config.maxRetries || 3,
retryDelay: this.state.config.retryDelay || 1000,
timeout: this.state.config.timeout || 15000,
apiKey: this.state.config.apiKey!,
workspaceId: this.state.config.workspaceId,
debug: this.state.config.debug || false,
useServerTracking: this.state.config.useServerTracking ?? true,
});
// Initialize event queue
this.eventQueue = new EventQueue(this.httpClient, {
maxQueueSize: this.state.config.maxQueueSize || 100,
batchSize: this.state.config.batchSize || 10,
flushInterval: this.state.config.flushInterval || 30000,
maxRetryCount: this.state.config.maxRetries || 3,
});
// Initialize visitor ID, anonymous ID and session
this.state.visitorId = await getOrCreateVisitorId();
this.state.anonymousId = await getOrCreateAnonymousId();
this.state.sessionId = await getOrCreateSessionId();
// Load persisted user data
await this.loadPersistedUserData();
// Initialize attribution manager
if (this.state.config.enableAttribution) {
await attributionManager.initialize();
}
// Initialize auto-events manager (asynchronously to avoid blocking)
if (this.state.config.enableAutoEvents) {
this.autoEventsManager = new AutoEventsManager(
this.track.bind(this),
this.state.config.autoEventConfig
);
// Initialize auto-events asynchronously to prevent blocking
setTimeout(async () => {
try {
await this.autoEventsManager?.initialize();
} catch (error) {
errorLog('Error initializing auto-events (non-blocking):', error as Error);
}
}, 100); // Small delay to ensure main thread isn't blocked
}
// Set up app state monitoring (also asynchronous)
setTimeout(() => {
try {
this.setupAppStateMonitoring();
} catch (error) {
errorLog('Error setting up app state monitoring (non-blocking):', error as Error);
}
}, 50);
// Initialize SKAdNetwork conversion encoder
if (config.skadTemplate) {
const template = ConversionTemplates[config.skadTemplate];
if (template) {
DatalyrSDK.conversionEncoder = new ConversionValueEncoder(template);
DatalyrSDK.debugEnabled = config.debug || false;
if (DatalyrSDK.debugEnabled) {
debugLog(`SKAdNetwork encoder initialized with template: ${config.skadTemplate}`);
debugLog(`SKAdNetwork bridge available: ${SKAdNetworkBridge.isAvailable()}`);
}
}
}
// SDK initialized successfully - set state before tracking install event
this.state.initialized = true;
// Check for app install (after SDK is marked as initialized)
if (attributionManager.isInstall()) {
const installData = await attributionManager.trackInstall();
await this.track('app_install', {
platform: Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : 'android',
sdk_version: '1.0.2',
...installData,
});
}
debugLog('Datalyr SDK initialized successfully', {
workspaceId: this.state.config.workspaceId,
visitorId: this.state.visitorId,
anonymousId: this.state.anonymousId,
sessionId: this.state.sessionId,
});
} catch (error) {
errorLog('Failed to initialize Datalyr SDK:', error as Error);
throw error;
}
}
/**
* Track a custom event
*/
async track(eventName: string, eventData?: EventData): Promise<void> {
try {
if (!this.state.initialized) {
errorLog('SDK not initialized. Call initialize() first.');
return;
}
if (!validateEventName(eventName)) {
errorLog(`Invalid event name: ${eventName}`);
return;
}
if (!validateEventData(eventData)) {
errorLog('Invalid event data provided');
return;
}
debugLog(`Tracking event: ${eventName}`, eventData);
const payload = await this.createEventPayload(eventName, eventData);
await this.eventQueue.enqueue(payload);
} catch (error) {
errorLog(`Error tracking event ${eventName}:`, error as Error);
}
}
/**
* Track a screen view
*/
async screen(screenName: string, properties?: EventData): Promise<void> {
const screenData: EventData = {
screen: screenName,
...properties,
};
await this.track('pageview', screenData);
// Also notify auto-events manager for automatic screen tracking
if (this.autoEventsManager) {
await this.autoEventsManager.trackScreenView(screenName, properties);
}
}
/**
* Identify a user
*/
async identify(userId: string, properties?: UserProperties): Promise<void> {
try {
if (!userId || typeof userId !== 'string') {
errorLog(`Invalid user ID for identify: ${userId}`);
return;
}
debugLog('Identifying user:', { userId, properties });
// Update current user ID
this.state.currentUserId = userId;
// Merge user properties
this.state.userProperties = { ...this.state.userProperties, ...properties };
// Persist user data
await this.persistUserData();
// Track $identify event for identity resolution
await this.track('$identify', {
userId,
anonymous_id: this.state.anonymousId,
...properties
});
} catch (error) {
errorLog('Error identifying user:', error as Error);
}
}
/**
* Alias a user (connect anonymous user to known user)
*/
async alias(newUserId: string, previousId?: string): Promise<void> {
try {
if (!newUserId || typeof newUserId !== 'string') {
errorLog(`Invalid user ID for alias: ${newUserId}`);
return;
}
const aliasData = {
newUserId,
previousId: previousId || this.state.visitorId,
visitorId: this.state.visitorId,
anonymousId: this.state.anonymousId, // Include for identity resolution
};
debugLog('Aliasing user:', aliasData);
// Track alias event
await this.track('alias', aliasData);
// Update current user ID
await this.identify(newUserId);
} catch (error) {
errorLog('Error aliasing user:', error as Error);
}
}
/**
* Reset user data (logout)
*/
async reset(): Promise<void> {
try {
debugLog('Resetting user data');
// Clear user data
this.state.currentUserId = undefined;
this.state.userProperties = {};
// Remove from storage
await Storage.removeItem(STORAGE_KEYS.USER_ID);
await Storage.removeItem(STORAGE_KEYS.USER_PROPERTIES);
// Generate new session
this.state.sessionId = await getOrCreateSessionId();
debugLog('User data reset completed');
} catch (error) {
errorLog('Error resetting user data:', error as Error);
}
}
/**
* Flush queued events immediately
*/
async flush(): Promise<void> {
try {
debugLog('Flushing events...');
await this.eventQueue.flush();
} catch (error) {
errorLog('Error flushing events:', error as Error);
}
}
/**
* Get SDK status and statistics
*/
getStatus(): {
initialized: boolean;
workspaceId: string;
visitorId: string;
anonymousId: string;
sessionId: string;
currentUserId?: string;
queueStats: any;
attribution: any;
} {
return {
initialized: this.state.initialized,
workspaceId: this.state.config.workspaceId || '',
visitorId: this.state.visitorId,
anonymousId: this.state.anonymousId,
sessionId: this.state.sessionId,
currentUserId: this.state.currentUserId,
queueStats: this.eventQueue.getStats(),
attribution: attributionManager.getAttributionSummary(),
};
}
/**
* Get the persistent anonymous ID
*/
getAnonymousId(): string {
return this.state.anonymousId;
}
/**
* Get detailed attribution data
*/
getAttributionData(): AttributionData {
return attributionManager.getAttributionData();
}
/**
* Set custom attribution data (for testing or manual attribution)
*/
async setAttributionData(data: Partial<AttributionData>): Promise<void> {
await attributionManager.setAttributionData(data);
}
/**
* Get current session information from auto-events
*/
getCurrentSession() {
return this.autoEventsManager?.getCurrentSession() || null;
}
/**
* Force end current session
*/
async endSession(): Promise<void> {
if (this.autoEventsManager) {
await this.autoEventsManager.forceEndSession();
}
}
/**
* Track app update manually
*/
async trackAppUpdate(previousVersion: string, currentVersion: string): Promise<void> {
if (this.autoEventsManager) {
await this.autoEventsManager.trackAppUpdate(previousVersion, currentVersion);
}
}
/**
* Track revenue event manually (purchases, subscriptions)
*/
async trackRevenue(eventName: string, properties?: EventData): Promise<void> {
if (this.autoEventsManager) {
await this.autoEventsManager.trackRevenueEvent(eventName, properties);
}
}
/**
* Update auto-events configuration
*/
updateAutoEventsConfig(config: Partial<AutoEventConfig>): void {
if (this.autoEventsManager) {
this.autoEventsManager.updateConfig(config);
}
}
// MARK: - SKAdNetwork Enhanced Methods
/**
* Track event with automatic SKAdNetwork conversion value encoding
*/
async trackWithSKAdNetwork(
event: string,
properties?: EventData
): Promise<void> {
// Existing tracking (keep exactly as-is)
await this.track(event, properties);
// NEW: Automatic SKAdNetwork encoding
if (!DatalyrSDK.conversionEncoder) {
if (DatalyrSDK.debugEnabled) {
errorLog('SKAdNetwork encoder not initialized. Pass skadTemplate in initialize()');
}
return;
}
const conversionValue = DatalyrSDK.conversionEncoder.encode(event, properties);
if (conversionValue > 0) {
const success = await SKAdNetworkBridge.updateConversionValue(conversionValue);
if (DatalyrSDK.debugEnabled) {
debugLog(`Event: ${event}, Conversion Value: ${conversionValue}, Success: ${success}`, properties);
}
} else if (DatalyrSDK.debugEnabled) {
debugLog(`No conversion value generated for event: ${event}`);
}
}
/**
* Track purchase with automatic revenue encoding
*/
async trackPurchase(
value: number,
currency = 'USD',
productId?: string
): Promise<void> {
const properties: Record<string, any> = { revenue: value, currency };
if (productId) properties.product_id = productId;
await this.trackWithSKAdNetwork('purchase', properties);
}
/**
* Track subscription with automatic revenue encoding
*/
async trackSubscription(
value: number,
currency = 'USD',
plan?: string
): Promise<void> {
const properties: Record<string, any> = { revenue: value, currency };
if (plan) properties.plan = plan;
await this.trackWithSKAdNetwork('subscribe', properties);
}
/**
* Get conversion value for testing (doesn't send to Apple)
*/
getConversionValue(event: string, properties?: Record<string, any>): number | null {
return DatalyrSDK.conversionEncoder?.encode(event, properties) || null;
}
// MARK: - Private Methods
/**
* Create an event payload with all required data
*/
private async createEventPayload(eventName: string, eventData?: EventData): Promise<EventPayload> {
const deviceInfo = await getDeviceInfo();
const fingerprintData = await createFingerprintData();
const attributionData = attributionManager.getAttributionData();
const payload: EventPayload = {
workspaceId: this.state.config.workspaceId || 'mobile_sdk',
visitorId: this.state.visitorId,
anonymousId: this.state.anonymousId, // Include persistent anonymous ID
sessionId: this.state.sessionId,
eventId: generateUUID(),
eventName,
eventData: {
...eventData,
// Include anonymous_id in event data for attribution
anonymous_id: this.state.anonymousId,
// Auto-captured mobile data
platform: Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : 'android',
os_version: deviceInfo.osVersion,
device_model: deviceInfo.model,
app_version: deviceInfo.appVersion,
app_build: deviceInfo.buildNumber,
network_type: getNetworkType(),
timestamp: Date.now(),
// Attribution data
...attributionData,
},
fingerprintData,
source: 'mobile_app',
timestamp: new Date().toISOString(),
};
// Add user data if available
if (this.state.currentUserId) {
payload.userId = this.state.currentUserId;
payload.eventData!.userId = this.state.currentUserId;
}
if (Object.keys(this.state.userProperties).length > 0) {
payload.userProperties = this.state.userProperties;
}
return payload;
}
/**
* Load persisted user data
*/
private async loadPersistedUserData(): Promise<void> {
try {
const [userId, userProperties] = await Promise.all([
Storage.getItem<string>(STORAGE_KEYS.USER_ID),
Storage.getItem<UserProperties>(STORAGE_KEYS.USER_PROPERTIES),
]);
if (userId) {
this.state.currentUserId = userId;
}
if (userProperties) {
this.state.userProperties = userProperties;
}
debugLog('Loaded persisted user data:', {
userId: this.state.currentUserId,
userProperties: this.state.userProperties,
});
} catch (error) {
errorLog('Error loading persisted user data:', error as Error);
}
}
/**
* Persist user data to storage
*/
private async persistUserData(): Promise<void> {
try {
await Promise.all([
this.state.currentUserId
? Storage.setItem(STORAGE_KEYS.USER_ID, this.state.currentUserId)
: Storage.removeItem(STORAGE_KEYS.USER_ID),
Storage.setItem(STORAGE_KEYS.USER_PROPERTIES, this.state.userProperties),
]);
} catch (error) {
errorLog('Error persisting user data:', error as Error);
}
}
/**
* Set up app state monitoring for lifecycle events (optimized)
*/
private setupAppStateMonitoring(): void {
try {
// Listen for app state changes (without tracking every change)
this.appStateSubscription = AppState.addEventListener('change', (nextAppState) => {
debugLog('App state changed:', nextAppState);
// Only handle meaningful state changes for session management
if (nextAppState === 'background') {
// Flush events before going to background
this.flush();
// Notify auto-events manager for session handling
if (this.autoEventsManager) {
this.autoEventsManager.handleAppBackground();
}
} else if (nextAppState === 'active') {
// App became active, ensure we have fresh session if needed
this.refreshSession();
// Notify auto-events manager for session handling
if (this.autoEventsManager) {
this.autoEventsManager.handleAppForeground();
}
}
});
} catch (error) {
errorLog('Error setting up app state monitoring:', error as Error);
}
}
/**
* Refresh session if needed
*/
private async refreshSession(): Promise<void> {
try {
const newSessionId = await getOrCreateSessionId();
if (newSessionId !== this.state.sessionId) {
this.state.sessionId = newSessionId;
debugLog('Session refreshed:', newSessionId);
}
} catch (error) {
errorLog('Error refreshing session:', error as Error);
}
}
/**
* Cleanup and destroy the SDK
*/
destroy(): void {
try {
debugLog('Destroying Datalyr SDK');
// Remove app state listener
if (this.appStateSubscription) {
this.appStateSubscription.remove();
this.appStateSubscription = null;
}
// Destroy event queue
this.eventQueue.destroy();
// Reset state
this.state.initialized = false;
debugLog('Datalyr SDK destroyed');
} catch (error) {
errorLog('Error destroying SDK:', error as Error);
}
}
}
// Create singleton instance
const datalyr = new DatalyrSDK();
// Export enhanced Datalyr class with static methods
export class Datalyr {
/**
* Initialize Datalyr with SKAdNetwork conversion value encoding
*/
static async initialize(config: DatalyrConfig): Promise<void> {
await datalyr.initialize(config);
}
/**
* Track event with automatic SKAdNetwork conversion value encoding
*/
static async trackWithSKAdNetwork(
event: string,
properties?: Record<string, any>
): Promise<void> {
await datalyr.trackWithSKAdNetwork(event, properties);
}
/**
* Track purchase with automatic revenue encoding
*/
static async trackPurchase(
value: number,
currency = 'USD',
productId?: string
): Promise<void> {
await datalyr.trackPurchase(value, currency, productId);
}
/**
* Track subscription with automatic revenue encoding
*/
static async trackSubscription(
value: number,
currency = 'USD',
plan?: string
): Promise<void> {
await datalyr.trackSubscription(value, currency, plan);
}
/**
* Get conversion value for testing (doesn't send to Apple)
*/
static getConversionValue(event: string, properties?: Record<string, any>): number | null {
return datalyr.getConversionValue(event, properties);
}
// Standard SDK methods
static async track(eventName: string, eventData?: EventData): Promise<void> {
await datalyr.track(eventName, eventData);
}
static async screen(screenName: string, properties?: EventData): Promise<void> {
await datalyr.screen(screenName, properties);
}
static async identify(userId: string, properties?: UserProperties): Promise<void> {
await datalyr.identify(userId, properties);
}
static async alias(newUserId: string, previousId?: string): Promise<void> {
await datalyr.alias(newUserId, previousId);
}
static async reset(): Promise<void> {
await datalyr.reset();
}
static async flush(): Promise<void> {
await datalyr.flush();
}
static getStatus() {
return datalyr.getStatus();
}
static getAnonymousId(): string {
return datalyr.getAnonymousId();
}
static getAttributionData(): AttributionData {
return datalyr.getAttributionData();
}
static async setAttributionData(data: Partial<AttributionData>): Promise<void> {
await datalyr.setAttributionData(data);
}
static getCurrentSession() {
return datalyr.getCurrentSession();
}
static async endSession(): Promise<void> {
await datalyr.endSession();
}
static async trackAppUpdate(previousVersion: string, currentVersion: string): Promise<void> {
await datalyr.trackAppUpdate(previousVersion, currentVersion);
}
static async trackRevenue(eventName: string, properties?: EventData): Promise<void> {
await datalyr.trackRevenue(eventName, properties);
}
static updateAutoEventsConfig(config: Partial<AutoEventConfig>): void {
datalyr.updateAutoEventsConfig(config);
}
}
// Export default instance for backward compatibility
export default datalyr;