import { Platform, AppState } from 'react-native'; import { DatalyrConfig, EventData, UserProperties, EventPayload, SDKState, AppState as AppStateType, AutoEventConfig, DeferredDeepLinkResult, } from './types'; import { getOrCreateVisitorId, getOrCreateAnonymousId, getOrCreateSessionId, rotateAnonymousId, clearSession, createDeviceContext, generateUUID, getDeviceInfo, getNetworkType, deriveCountryFromLocale, 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 { journeyManager } from './journey'; import { createAutoEventsManager, AutoEventsManager, SessionData } from './auto-events'; import { ConversionValueEncoder, ConversionTemplates } from './ConversionValueEncoder'; import { SKAdNetworkBridge } from './native/SKAdNetworkBridge'; import { appleSearchAdsIntegration, playInstallReferrerIntegration } from './integrations'; import { AppleSearchAdsAttribution, AdvertiserInfoBridge } from './native/DatalyrNativeBridge'; import { networkStatusManager } from './network-status'; export class DatalyrSDK { private state: SDKState; private httpClient: HttpClient; private eventQueue: EventQueue; private autoEventsManager: AutoEventsManager | null = null; private appStateSubscription: any = null; private networkStatusUnsubscribe: (() => void) | null = null; private cachedAdvertiserInfo: any = null; private initializing: boolean = false; private static conversionEncoder?: ConversionValueEncoder; private static debugEnabled = false; /** Events that arrived before initialize() completed. Flushed once init finishes. */ private preInitQueue: Array<{ eventName: string; eventData?: EventData }> = []; private static readonly PRE_INIT_QUEUE_MAX = 50; constructor() { // Initialize state with defaults this.state = { initialized: false, config: { workspaceId: '', apiKey: '', debug: false, endpoint: 'https://ingest.datalyr.com/track', useServerTracking: true, // Default to server-side maxRetries: 3, retryDelay: 1000, batchSize: 10, flushInterval: 30000, maxQueueSize: 100, respectDoNotTrack: true, enableAutoEvents: true, enableAttribution: 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 { try { debugLog('Initializing Datalyr SDK...', { workspaceId: config.workspaceId }); // Idempotent init: a repeat initialize() (hot-reload, re-login, double-call) must not // re-run — it would orphan the prior AppState + network subscriptions (the first // becomes unremovable), double-fire flush/$network_status_change, and emit a duplicate // session_start. Mirrors the iOS SDK's `guard !initialized`. if (this.state.initialized || this.initializing) { debugLog('SDK already initialized; ignoring repeat initialize()'); return; } this.initializing = true; // 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 }; // Tear down the placeholder queue built in the constructor BEFORE installing the // configured one. That placeholder has an empty apiKey, started its own 30s flush // timer, and loaded the persisted queue from the SAME storage key — left alive it // flushes leftover events through an empty-key client (→ 401 → dead-letter) and // races the real queue on storage. destroy() stops its timer for good. this.eventQueue.destroy(); // Initialize HTTP client with server-side API this.httpClient = new HttpClient(this.state.config.endpoint || 'https://ingest.datalyr.com/track', { 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, }); // PARALLEL INITIALIZATION: IDs and core managers // Run ID creation and core manager initialization in parallel for faster startup const [visitorId, anonymousId, sessionId] = await Promise.all([ getOrCreateVisitorId(), getOrCreateAnonymousId(), getOrCreateSessionId(), // These run concurrently but don't return values we need to capture this.loadPersistedUserData(), this.state.config.enableAttribution ? attributionManager.initialize() : Promise.resolve(), journeyManager.initialize(), ]); this.state.visitorId = visitorId; this.state.anonymousId = anonymousId; this.state.sessionId = sessionId; // Record initial attribution to journey if this is a new session with attribution const initialAttribution = attributionManager.getAttributionData(); if (initialAttribution.utm_source || initialAttribution.fbclid || initialAttribution.gclid || initialAttribution.lyr) { await journeyManager.recordAttribution(this.state.sessionId, { source: initialAttribution.utm_source || initialAttribution.campaign_source, medium: initialAttribution.utm_medium || initialAttribution.campaign_medium, campaign: initialAttribution.utm_campaign || initialAttribution.campaign_name, fbclid: initialAttribution.fbclid, gclid: initialAttribution.gclid, ttclid: initialAttribution.ttclid, clickIdType: initialAttribution.fbclid ? 'fbclid' : initialAttribution.gclid ? 'gclid' : initialAttribution.ttclid ? 'ttclid' : undefined, lyr: initialAttribution.lyr, }); } // 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, // Canonical session id getter — unifies session_start/end's session_id with the // wire context.session_id (state.sessionId). () => this.state.sessionId ); // 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 (synchronous, no await needed) 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()}`); } } } // PARALLEL INITIALIZATION: Network monitoring and platform integrations // These are independent and can run concurrently for faster startup const platformInitPromises: Promise[] = [ // Network monitoring this.initializeNetworkMonitoring(), // Apple Search Ads (iOS only) appleSearchAdsIntegration.initialize(config.debug), // Google Play Install Referrer (Android only) playInstallReferrerIntegration.initialize(), ]; // Wait for all platform integrations to complete await Promise.all(platformInitPromises); // Cache advertiser info (IDFA/GAID, ATT status) once at init to avoid per-event native bridge calls try { this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo(); } catch (error) { errorLog('Failed to cache advertiser info:', error as Error); } debugLog('Platform integrations initialized', { appleSearchAds: appleSearchAdsIntegration.isAvailable(), playInstallReferrer: playInstallReferrerIntegration.isAvailable(), }); // SDK initialized successfully - set state before tracking install event this.state.initialized = true; // Flush any events that were queued before init completed (e.g. screen tracking) if (this.preInitQueue.length > 0) { debugLog(`Flushing ${this.preInitQueue.length} pre-init event(s)`); const queued = [...this.preInitQueue]; this.preInitQueue = []; for (const { eventName, eventData } of queued) { await this.track(eventName, eventData); } } // Check for app install (after SDK is marked as initialized) if (attributionManager.isInstall()) { // iOS: Attempt deferred web-to-app attribution via IP matching before tracking install // Android: Play Store referrer is handled by playInstallReferrerIntegration if (Platform.OS === 'ios') { await this.fetchDeferredWebAttribution(); } const installData = await attributionManager.trackInstall(); await this.track('app_install', { platform: Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : 'android', sdk_version: '1.7.12', ...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; } finally { // Always clear the in-flight flag. `state.initialized` alone gates repeats; leaving // `initializing` true on the SUCCESS path (the old bug) permanently bricked the SDK // after a destroy()→initialize() cycle. On failure this also re-allows a retry. this.initializing = false; } } /** * Track a custom event */ async track(eventName: string, eventData?: EventData): Promise { try { if (!this.state.initialized) { // Queue events that arrive before init completes instead of dropping them if (this.preInitQueue.length < DatalyrSDK.PRE_INIT_QUEUE_MAX) { debugLog(`Queuing pre-init event: ${eventName}`); this.preInitQueue.push({ eventName, eventData }); } else { errorLog('Pre-init event queue full, dropping event:', eventName as unknown as Error); } 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); // Update session activity counters (refreshes lastActivity so session_end duration // and the idle-window timeout track real usage). Skip the SDK's own session lifecycle // events so `events` counts real activity, not the session_start/end bookkeeping. if (this.autoEventsManager && eventName !== 'session_start' && eventName !== 'session_end') { await this.autoEventsManager.onEvent(eventName); } } catch (error) { errorLog(`Error tracking event ${eventName}:`, error as Error); } } /** * Track a screen view */ async screen(screenName: string, properties?: EventData): Promise { const screenData: EventData = { screen: screenName, ...properties, }; // Enrich with session data (pageview count, previous screen) if available. // User-provided properties take precedence over enrichment. if (this.autoEventsManager) { const enrichment = this.autoEventsManager.getScreenViewEnrichment(); if (enrichment) { for (const [key, value] of Object.entries(enrichment)) { if (!(key in screenData)) { screenData[key] = value; } } } // Update session counters (does NOT fire a second event) await this.autoEventsManager.recordScreenView(screenName); } await this.track('pageview', screenData); } /** * Identify a user */ async identify(userId: string, properties?: UserProperties): Promise { 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 }); // Fetch and merge web attribution if email is provided if (this.state.config.enableWebToAppAttribution !== false) { const email = properties?.email || (typeof userId === 'string' && userId.includes('@') ? userId : null); if (email) { await this.fetchAndMergeWebAttribution(email); } } // RN-9: removed a dead "Advanced Matching" block here that destructured // email/phone/name/dob/gender/city into locals and then discarded them (nothing // was forwarded). The identify traits already ship to the backend via the // $identify event above; the pixel-side advanced matching is handled server-side. } catch (error) { errorLog('Error identifying user:', error as Error); } } /** * Fetch web attribution data for user and merge into mobile session * Called automatically during identify() if email is provided */ /** Stable (cross-launch) non-crypto hash (FNV-1a) so we store no raw email. */ private stableEmailHash(email: string): string { let h = 0x811c9dc5; const s = email.toLowerCase(); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(16); } private async fetchAndMergeWebAttribution(email: string): Promise { try { // Web→app attribution is a one-time, install-time fact — resolve it AT MOST // ONCE per email per install. Apps call identify(email) every session; without // this, each one re-fires /attribution/lookup (~100k/day from a single app, // 99.7% misses). Skip if we've already definitively resolved/checked this email. const emailHash = this.stableEmailHash(email); const checkedKey = 'datalyr_web_attribution_checked'; const checked = (await Storage.getItem(checkedKey)) || []; if (checked.includes(emailHash)) { debugLog('Web attribution already checked this install for this email; skipping lookup'); return; } debugLog('Fetching web attribution for email:', email); // Call API endpoint to get web attribution const response = await fetch('https://api.datalyr.com/attribution/lookup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Datalyr-API-Key': this.state.config.apiKey!, }, body: JSON.stringify({ email }), }); if (!response.ok) { debugLog('Failed to fetch web attribution:', response.status); return; // transient/non-200 — don't mark checked, retry on next identify } // Parse the body BEFORE marking checked — a body read/parse failure (dropped // connection mid-body, truncated/HTML 200) would otherwise permanently suppress this // once-per-install lookup. On parse failure the catch logs and leaves checked unset, // so the next identify(email) retries. const result = await response.json() as { found: boolean; attribution?: any }; // Definitive 200 answer (found or not) — record it so repeated identify(email) // calls don't re-run this immutable, install-time lookup. (Capped to bound // growth from rare account switches.) await Storage.setItem(checkedKey, [...checked, emailHash].slice(-20)); if (!result.found || !result.attribution) { debugLog('No web attribution found for user'); return; } const webAttribution = result.attribution; debugLog('Web attribution found:', { visitor_id: webAttribution.visitor_id, has_fbclid: !!webAttribution.fbclid, has_gclid: !!webAttribution.gclid, utm_source: webAttribution.utm_source, }); // Merge BEFORE tracking (matches the IP/deferred path). This makes // $web_attribution_matched self-consistent — see createEventPayload, which spreads // attributionData FIRST so the explicit web-recovered fields below win over any // pre-existing device attribution. (Old order tracked before merging, so a device // with its own deep-link attribution corrupted the match event.) await attributionManager.mergeWebAttribution(webAttribution); // Emit the canonical web→app bridge event. The email/identify path and the // IP/deferred path BOTH fire `$web_attribution_matched`, distinguished only // by `match_method` — so server bridges (Meta CAPI recovery, lyr) and match // dashboards see one event name. (Historically this path fired a separate // `$web_attribution_merged` that no server reader consumed, so email-only // matches silently never bridged webhook conversions.) await this.track('$web_attribution_matched', { web_visitor_id: webAttribution.visitor_id, web_user_id: webAttribution.user_id, fbclid: webAttribution.fbclid, gclid: webAttribution.gclid, ttclid: webAttribution.ttclid, gbraid: webAttribution.gbraid, wbraid: webAttribution.wbraid, // Emit `_fbp`/`_fbc` (the Meta cookie names the attribution MV + postback extract // via JSONExtractString(event_data,'_fbp')) as well as the bare keys — the bare // `fbp`/`fbc` alone have no reader, so recovered web fbp/fbc never reached Meta CAPI. fbp: webAttribution.fbp, fbc: webAttribution.fbc, _fbp: webAttribution.fbp, _fbc: webAttribution.fbc, utm_source: webAttribution.utm_source, utm_medium: webAttribution.utm_medium, utm_campaign: webAttribution.utm_campaign, utm_content: webAttribution.utm_content, utm_term: webAttribution.utm_term, web_timestamp: webAttribution.timestamp, match_method: 'email', }); debugLog('Successfully merged web attribution into mobile session'); } catch (error) { errorLog('Error fetching web attribution:', error as Error); // Non-blocking - continue even if attribution fetch fails } } /** * Fetch deferred web attribution on first app install. * Uses IP-based matching (iOS) or Play Store referrer (Android) to recover * attribution data (fbclid, utm_*, etc.) from a prelander web visit. * Called automatically during initialize() when a fresh install is detected. */ private async fetchDeferredWebAttribution(): Promise { if (!this.state.config?.apiKey) { debugLog('API key not available for deferred attribution fetch'); return; } try { debugLog('Fetching deferred web attribution via IP matching...'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); const response = await fetch('https://api.datalyr.com/attribution/deferred-lookup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Datalyr-API-Key': this.state.config.apiKey, }, body: JSON.stringify({ platform: Platform.OS }), signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) { debugLog('Deferred attribution lookup failed:', response.status); return; } const result = await response.json() as { found: boolean; attribution?: any }; if (!result.found || !result.attribution) { debugLog('No deferred web attribution found for this IP'); return; } const webAttribution = result.attribution; debugLog('Deferred web attribution found:', { visitor_id: webAttribution.visitor_id, has_fbclid: !!webAttribution.fbclid, has_gclid: !!webAttribution.gclid, utm_source: webAttribution.utm_source, }); // Merge web attribution into current session await attributionManager.mergeWebAttribution(webAttribution); // Track match event for analytics await this.track('$web_attribution_matched', { web_visitor_id: webAttribution.visitor_id, web_user_id: webAttribution.user_id, fbclid: webAttribution.fbclid, gclid: webAttribution.gclid, ttclid: webAttribution.ttclid, gbraid: webAttribution.gbraid, wbraid: webAttribution.wbraid, // Emit `_fbp`/`_fbc` (the Meta cookie names the attribution MV + postback extract // via JSONExtractString(event_data,'_fbp')) as well as the bare keys — the bare // `fbp`/`fbc` alone have no reader, so recovered web fbp/fbc never reached Meta CAPI. fbp: webAttribution.fbp, fbc: webAttribution.fbc, _fbp: webAttribution.fbp, _fbc: webAttribution.fbc, utm_source: webAttribution.utm_source, utm_medium: webAttribution.utm_medium, utm_campaign: webAttribution.utm_campaign, utm_content: webAttribution.utm_content, utm_term: webAttribution.utm_term, web_timestamp: webAttribution.timestamp, match_method: 'ip', }); debugLog('Successfully merged deferred web attribution'); } catch (error) { errorLog('Error fetching deferred web attribution:', error as Error); // Non-blocking - email-based fallback will catch this on identify() } } /** * Alias a user (connect anonymous user to known user) */ async alias(newUserId: string, previousId?: string): Promise { try { if (!newUserId || typeof newUserId !== 'string') { errorLog(`Invalid user ID for alias: ${newUserId}`); return; } const aliasData = { newUserId, // ingest's $alias link builder reads camelCase `userId`/`previousId`; `newUserId` // alone wrote no link. Emit `userId` too. (Event name must be `$alias`, below.) userId: newUserId, previousId: previousId || this.state.visitorId, visitorId: this.state.visitorId, anonymousId: this.state.anonymousId, // Include for identity resolution }; debugLog('Aliasing user:', aliasData); // Track $alias (NOT 'alias' — ingest matches only the '$alias' event name, so the // bare name wrote zero visitor_user_links: alias-based identity merges were dropped). 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 { 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); // Rotate the anonymous ID so the next user is NOT linked to this user's anon id // (otherwise logout→login on one device merges both users in the identity graph and // the Meta CAPI bridge leaks click-ids/PII between them). this.state.anonymousId = await rotateAnonymousId(); // Force a genuinely new session. getOrCreateSessionId() resumes any session <30min // old (always true at logout), so clear the stored session first — otherwise the // next user's events would share the previous user's session id. await clearSession(); 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 { 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; journey: 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(), journey: journeyManager.getJourneySummary(), }; } /** * Get the persistent anonymous ID */ getAnonymousId(): string { return this.state.anonymousId; } /** * Get detailed attribution data (includes journey tracking data) */ getAttributionData(): AttributionData & Record { const attribution = attributionManager.getAttributionData(); const journeyData = journeyManager.getAttributionData(); // Merge attribution with journey data return { ...attribution, ...journeyData, }; } /** * Get journey tracking summary */ getJourneySummary() { return journeyManager.getJourneySummary(); } /** * Get full customer journey (all touchpoints) */ getJourney() { return journeyManager.getJourney(); } /** * Set custom attribution data (for testing or manual attribution) */ async setAttributionData(data: Partial): Promise { await attributionManager.setAttributionData(data); } /** * Get current session information from auto-events */ getCurrentSession() { return this.autoEventsManager?.getCurrentSession() || null; } /** * Force end current session */ async endSession(): Promise { if (this.autoEventsManager) { await this.autoEventsManager.forceEndSession(); } } /** * Track app update manually */ async trackAppUpdate(previousVersion: string, currentVersion: string): Promise { await this.track('app_update', { previous_version: previousVersion, current_version: currentVersion, }); } /** * Update auto-events configuration */ updateAutoEventsConfig(config: Partial): void { if (this.autoEventsManager) { this.autoEventsManager.updateConfig(config); } } // MARK: - SKAdNetwork Enhanced Methods /** * Track event with automatic SKAdNetwork conversion value encoding * Uses SKAN 4.0 on iOS 16.1+ with coarse values and lock window support */ async trackWithSKAdNetwork( event: string, properties?: EventData ): Promise { // Existing tracking (keep exactly as-is) await this.track(event, properties); // Automatic SKAdNetwork encoding with SKAN 4.0 support if (!DatalyrSDK.conversionEncoder) { if (DatalyrSDK.debugEnabled) { errorLog('SKAdNetwork encoder not initialized. Pass skadTemplate in initialize()'); } return; } // Use SKAN 4.0 encoding (includes coarse value and lock window) const result = DatalyrSDK.conversionEncoder.encodeWithSKAN4(event, properties); if (result.fineValue > 0 || result.priority > 0) { // Use SKAN 4.0 method (automatically falls back to SKAN 3.0 on older iOS) const success = await SKAdNetworkBridge.updatePostbackConversionValue(result); if (DatalyrSDK.debugEnabled) { debugLog(`SKAN: event=${event}, fine=${result.fineValue}, coarse=${result.coarseValue}, lock=${result.lockWindow}, success=${success}`, properties); } } else if (DatalyrSDK.debugEnabled) { debugLog(`No conversion value generated for event: ${event}`); } } /** * Track purchase with automatic revenue encoding and platform forwarding */ async trackPurchase( value: number, currency = 'USD', productId?: string ): Promise { // Emit BOTH `value` and `revenue` — the conversion-rule/CAPI postback value extractor // resolves a configured value_path (commonly `value`), while SKAN/value_usd read either; // sending only `revenue` risked a $0 value on rules wired to `value`. Matches the other // commerce helpers (trackAddToCart/etc.), which already use `value`. const properties: Record = { value, revenue: value, currency }; if (productId) properties.product_id = productId; await this.trackWithSKAdNetwork('purchase', properties); } /** * Track subscription with automatic revenue encoding and platform forwarding */ async trackSubscription( value: number, currency = 'USD', plan?: string ): Promise { // Emit BOTH `value` and `revenue` — the conversion-rule/CAPI postback value extractor // resolves a configured value_path (commonly `value`), while SKAN/value_usd read either; // sending only `revenue` risked a $0 value on rules wired to `value`. Matches the other // commerce helpers (trackAddToCart/etc.), which already use `value`. const properties: Record = { value, revenue: value, currency }; if (plan) properties.plan = plan; await this.trackWithSKAdNetwork('subscribe', properties); } // MARK: - Standard E-commerce Events /** * Track add to cart event */ async trackAddToCart( value: number, currency = 'USD', productId?: string, productName?: string ): Promise { const properties: Record = { value, currency }; if (productId) properties.product_id = productId; if (productName) properties.product_name = productName; await this.trackWithSKAdNetwork('add_to_cart', properties); } /** * Track view content/product event */ async trackViewContent( contentId?: string, contentName?: string, contentType = 'product', value?: number, currency?: string ): Promise { const properties: Record = { content_type: contentType }; if (contentId) properties.content_id = contentId; if (contentName) properties.content_name = contentName; if (value !== undefined) properties.value = value; if (currency) properties.currency = currency; await this.track('view_content', properties); } /** * Track initiate checkout event */ async trackInitiateCheckout( value: number, currency = 'USD', numItems?: number, productIds?: string[] ): Promise { const properties: Record = { value, currency }; if (numItems !== undefined) properties.num_items = numItems; if (productIds) properties.product_ids = productIds; await this.trackWithSKAdNetwork('initiate_checkout', properties); } /** * Track complete registration event */ async trackCompleteRegistration(method?: string): Promise { const properties: Record = {}; if (method) properties.method = method; await this.trackWithSKAdNetwork('complete_registration', properties); } /** * Track search event */ async trackSearch(query: string, resultIds?: string[]): Promise { const properties: Record = { query }; if (resultIds) properties.result_ids = resultIds; await this.track('search', properties); } /** * Track lead/contact form submission */ async trackLead(value?: number, currency?: string): Promise { const properties: Record = {}; if (value !== undefined) properties.value = value; if (currency) properties.currency = currency; await this.trackWithSKAdNetwork('lead', properties); } /** * Track add payment info event */ async trackAddPaymentInfo(success = true): Promise { await this.track('add_payment_info', { success }); } // MARK: - Platform Integration Methods /** * Get deferred attribution data from platform SDKs */ getDeferredAttributionData(): DeferredDeepLinkResult | null { return null; } /** * Get platform integration status */ getPlatformIntegrationStatus(): { appleSearchAds: boolean; playInstallReferrer: boolean } { return { appleSearchAds: appleSearchAdsIntegration.isAvailable(), playInstallReferrer: playInstallReferrerIntegration.isAvailable(), }; } /** * Get Apple Search Ads attribution data * Returns attribution if user installed via Apple Search Ads, null otherwise */ getAppleSearchAdsAttribution(): AppleSearchAdsAttribution | null { return appleSearchAdsIntegration.getAttributionData(); } /** * Get Google Play Install Referrer attribution data (Android only) * Returns referrer data if available, null otherwise */ getPlayInstallReferrer(): Record | null { const data = playInstallReferrerIntegration.getReferrerData(); return data ? playInstallReferrerIntegration.getAttributionData() : null; } // MARK: - Third-Party Integration Methods /** * Get attribution data formatted for Superwall's setUserAttributes() * Returns a flat Record with only non-empty values */ getSuperwallAttributes(): Record { const attribution = attributionManager.getAttributionData(); const advertiser = this.cachedAdvertiserInfo; const attrs: Record = {}; const set = (key: string, value: any) => { if (value != null && String(value) !== '') attrs[key] = String(value); }; set('datalyr_id', this.state.visitorId); set('media_source', attribution.utm_source); set('campaign', attribution.utm_campaign); set('adgroup', attribution.adset_id || attribution.utm_content); set('ad', attribution.ad_id); set('keyword', attribution.keyword); set('network', attribution.network); set('utm_source', attribution.utm_source); set('utm_medium', attribution.utm_medium); set('utm_campaign', attribution.utm_campaign); set('utm_term', attribution.utm_term); set('utm_content', attribution.utm_content); set('lyr', attribution.lyr); set('fbclid', attribution.fbclid); set('gclid', attribution.gclid); set('ttclid', attribution.ttclid); set('idfa', advertiser?.idfa); set('gaid', advertiser?.gaid); if (advertiser?.att_status != null) { const statusMap: Record = { 0: 'notDetermined', 1: 'restricted', 2: 'denied', 3: 'authorized' }; set('att_status', statusMap[advertiser.att_status] || String(advertiser.att_status)); } return attrs; } /** * Get attribution data formatted for RevenueCat's Purchases.setAttributes() * Returns a flat Record with $-prefixed reserved keys */ getRevenueCatAttributes(): Record { const attribution = attributionManager.getAttributionData(); const advertiser = this.cachedAdvertiserInfo; const attrs: Record = {}; const set = (key: string, value: any) => { if (value != null && String(value) !== '') attrs[key] = String(value); }; // Reserved attributes ($ prefix) set('$datalyrId', this.state.visitorId); set('$mediaSource', attribution.utm_source); set('$campaign', attribution.utm_campaign); set('$adGroup', attribution.adset_id); set('$ad', attribution.ad_id); set('$keyword', attribution.keyword); set('$idfa', advertiser?.idfa); set('$gpsAdId', advertiser?.gaid); if (advertiser?.att_status != null) { const statusMap: Record = { 0: 'notDetermined', 1: 'restricted', 2: 'denied', 3: 'authorized' }; set('$attConsentStatus', statusMap[advertiser.att_status] || String(advertiser.att_status)); } // Custom attributes set('utm_source', attribution.utm_source); set('utm_medium', attribution.utm_medium); set('utm_campaign', attribution.utm_campaign); set('utm_term', attribution.utm_term); set('utm_content', attribution.utm_content); set('lyr', attribution.lyr); set('fbclid', attribution.fbclid); set('gclid', attribution.gclid); set('ttclid', attribution.ttclid); set('wbraid', attribution.wbraid); set('gbraid', attribution.gbraid); set('network', attribution.network); set('creative_id', attribution.creative_id); return attrs; } /** * Update tracking authorization status * Call this AFTER the user responds to the ATT permission dialog */ async updateTrackingAuthorization(enabled: boolean): Promise { if (!this.state.initialized) { errorLog('SDK not initialized. Call initialize() first.'); return; } // Refresh cached advertiser info after ATT status change try { this.cachedAdvertiserInfo = await AdvertiserInfoBridge.getAdvertiserInfo(); } catch (error) { errorLog('Failed to refresh advertiser info:', error as Error); } // Track ATT status event await this.track('$att_status', { authorized: enabled, status: enabled ? 3 : 2, status_name: enabled ? 'authorized' : 'denied', }); debugLog(`ATT status updated: ${enabled ? 'authorized' : 'denied'}`); } /** * Handle deferred deep link data from platform SDKs */ private async handleDeferredDeepLink(data: DeferredDeepLinkResult): Promise { try { debugLog('Processing deferred deep link:', data); // Track deferred attribution event await this.track('$deferred_deep_link', { url: data.url, source: data.source, fbclid: data.fbclid, ttclid: data.ttclid, utm_source: data.utmSource, utm_medium: data.utmMedium, utm_campaign: data.utmCampaign, utm_content: data.utmContent, utm_term: data.utmTerm, campaign_id: data.campaignId, adset_id: data.adsetId, ad_id: data.adId, }); // Merge into attribution manager await attributionManager.mergeWebAttribution({ fbclid: data.fbclid, ttclid: data.ttclid, utm_source: data.utmSource, utm_medium: data.utmMedium, utm_campaign: data.utmCampaign, utm_content: data.utmContent, utm_term: data.utmTerm, }); debugLog('Deferred deep link processed successfully'); } catch (error) { errorLog('Error processing deferred deep link:', error as Error); } } /** * Get conversion value for testing (doesn't send to Apple) */ getConversionValue(event: string, properties?: Record): 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 { const deviceInfo = await getDeviceInfo(); const deviceContext = await createDeviceContext(); const attributionData = attributionManager.getAttributionData(); // Get Apple Search Ads attribution if available const asaAttribution = appleSearchAdsIntegration.getAttributionData(); const asaData = asaAttribution?.attribution ? { asa_campaign_id: asaAttribution.campaignId, asa_campaign_name: asaAttribution.campaignName, asa_ad_group_id: asaAttribution.adGroupId, asa_ad_group_name: asaAttribution.adGroupName, asa_keyword_id: asaAttribution.keywordId, asa_keyword: asaAttribution.keyword, asa_org_id: asaAttribution.orgId, asa_org_name: asaAttribution.orgName, asa_click_date: asaAttribution.clickDate, asa_conversion_type: asaAttribution.conversionType, asa_country_or_region: asaAttribution.countryOrRegion, } : {}; // Use cached advertiser info (IDFA/GAID, ATT status) — cached at init, refreshed on ATT change const advertiserInfo = this.cachedAdvertiserInfo; 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: { // Auto-captured mobile data + persisted attribution are spread FIRST so the // caller's explicit event properties (`...eventData`, spread LAST) WIN. Previously // attribution was spread after eventData, silently clobbering caller-passed fields // like track('purchase', { utm_source: 'push' }). // 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, app_name: deviceInfo.bundleId, // Best available app name app_namespace: deviceInfo.bundleId, screen_width: deviceInfo.screenWidth, screen_height: deviceInfo.screenHeight, locale: deviceInfo.locale, timezone: deviceInfo.timezone, // ISO-3166-1 alpha-2 derived from device locale ('en-US' → 'US'). For // users who skip the web lander entirely (no web→app bridge to inherit // geo from), this is the only zero-config country signal — meta.js // USER_DATA_PATHS.country picks it up at the top level and hashes for // CAPI's `country` match key. Bridge-recovered geo still overrides // when present (server-side spread order). country: deriveCountryFromLocale(deviceInfo.locale) || undefined, carrier: deviceInfo.carrier, network_type: getNetworkType(), sdk_version: '1.7.12', // Advertiser data (IDFA/GAID, ATT status) for server-side postback ...(advertiserInfo ? { idfa: advertiserInfo.idfa, idfv: advertiserInfo.idfv, gaid: advertiserInfo.gaid, att_status: advertiserInfo.att_status, advertiser_tracking_enabled: advertiserInfo.advertiser_tracking_enabled, } : {}), // Attribution data ...attributionData, // Apple Search Ads attribution (iOS) ...asaData, // Google Play Install Referrer (Android) — gclid/gbraid/wbraid/utm_*. Fill it // ONLY when the web→app bridge hasn't already recovered ad attribution, so a // web-sourced gclid/utm isn't clobbered. (Was fetched at init but never merged // at all, dropping Android Google Ads install attribution.) ...((attributionData.gclid || attributionData.utm_source) ? {} : playInstallReferrerIntegration.getAttributionData()), // Caller-supplied event properties WIN over all auto/attribution fields above. ...eventData, // Always SDK-stamped (not caller-overridable). timestamp: Date.now(), }, deviceContext, source: 'mobile_app', timestamp: new Date().toISOString(), }; // $alias/$identify carry their OWN userId in eventData (the NEW id). alias() fires // $alias BEFORE its internal identify(), so state.currentUserId is still the OLD/ // persisted id here — overwriting eventData.userId with it would write the // visitor_user_links row to the WRONG user (ingest reads ed.userId, camelCase). Only // fill userId from currentUserId when the event didn't already provide one. if (this.state.currentUserId) { payload.userId = this.state.currentUserId; if (payload.eventData && !('userId' in payload.eventData)) { payload.eventData.userId = this.state.currentUserId; } } // Pre-init alias() snapshots previousId from state.visitorId while it's still '' (the // constructor default); the replayed $alias would ship empty previousId and the server // link builder requires it truthy (`if (previousId && userId ...)`). Resolve it at // payload-creation time (post-init, so visitorId is populated). if (eventName === '$alias' && payload.eventData && !payload.eventData.previousId && this.state.visitorId) { payload.eventData.previousId = this.state.visitorId; } if (Object.keys(this.state.userProperties).length > 0) { payload.userProperties = this.state.userProperties; } return payload; } /** * Load persisted user data */ private async loadPersistedUserData(): Promise { try { const [userId, userProperties] = await Promise.all([ Storage.getItem(STORAGE_KEYS.USER_ID), Storage.getItem(STORAGE_KEYS.USER_PROPERTIES), ]); // Only restore the persisted user when an identify() hasn't ALREADY set one. This // load runs concurrently in initialize()'s Promise.all; an app that calls // initialize() without awaiting and then identify(B) mid-init would otherwise have // the previous launch's persisted user A clobber B (and the replayed $identify(B) // would silently become A). Don't overwrite a live identity. if (userId && this.state.currentUserId === undefined) { this.state.currentUserId = userId; } if (userProperties && Object.keys(this.state.userProperties).length === 0) { 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 { 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); } } /** * Initialize network status monitoring * Automatically updates event queue when network status changes */ private async initializeNetworkMonitoring(): Promise { try { await networkStatusManager.initialize(); // Update event queue with current network status this.state.isOnline = networkStatusManager.isOnline(); this.eventQueue.setOnlineStatus(this.state.isOnline); // Subscribe to network changes this.networkStatusUnsubscribe = networkStatusManager.subscribe((state) => { const isOnline = state.isConnected && (state.isInternetReachable !== false); this.state.isOnline = isOnline; this.eventQueue.setOnlineStatus(isOnline); // Track network status change event (only if SDK is fully initialized) if (this.state.initialized) { this.track('$network_status_change', { is_online: isOnline, network_type: state.type, is_internet_reachable: state.isInternetReachable, }).catch(() => { // Ignore errors for network status events }); } }); debugLog(`Network monitoring initialized, online: ${this.state.isOnline}`); } catch (error) { errorLog('Error initializing network monitoring (non-blocking):', error as Error); // Default to online if monitoring fails this.state.isOnline = true; this.eventQueue.setOnlineStatus(true); } } /** * 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(); // Refresh network status when coming back from background networkStatusManager.refresh(); // 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 { 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; } // Remove network status listener if (this.networkStatusUnsubscribe) { this.networkStatusUnsubscribe(); this.networkStatusUnsubscribe = null; } // Destroy network status manager networkStatusManager.destroy(); // Tear down attribution + auto-events so destroy()→initialize() doesn't leak the old // Linking 'url' subscription / session timers and a fresh init can re-listen. attributionManager.destroy(); if (this.autoEventsManager) { this.autoEventsManager.destroy(); this.autoEventsManager = null; } // Destroy event queue this.eventQueue.destroy(); // Reset state. `initializing` MUST be cleared too, or a subsequent initialize() // hits the idempotent guard and the SDK never re-initializes (silent total loss). this.state.initialized = false; this.initializing = 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 { await datalyr.initialize(config); } /** * Track event with automatic SKAdNetwork conversion value encoding */ static async trackWithSKAdNetwork( event: string, properties?: Record ): Promise { await datalyr.trackWithSKAdNetwork(event, properties); } /** * Track purchase with automatic revenue encoding */ static async trackPurchase( value: number, currency = 'USD', productId?: string ): Promise { await datalyr.trackPurchase(value, currency, productId); } /** * Track subscription with automatic revenue encoding */ static async trackSubscription( value: number, currency = 'USD', plan?: string ): Promise { await datalyr.trackSubscription(value, currency, plan); } /** * Get conversion value for testing (doesn't send to Apple) */ static getConversionValue(event: string, properties?: Record): number | null { return datalyr.getConversionValue(event, properties); } // Standard SDK methods static async track(eventName: string, eventData?: EventData): Promise { await datalyr.track(eventName, eventData); } static async screen(screenName: string, properties?: EventData): Promise { await datalyr.screen(screenName, properties); } static async identify(userId: string, properties?: UserProperties): Promise { await datalyr.identify(userId, properties); } static async alias(newUserId: string, previousId?: string): Promise { await datalyr.alias(newUserId, previousId); } static async reset(): Promise { await datalyr.reset(); } static async flush(): Promise { 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): Promise { await datalyr.setAttributionData(data); } static getCurrentSession() { return datalyr.getCurrentSession(); } static async endSession(): Promise { await datalyr.endSession(); } static async trackAppUpdate(previousVersion: string, currentVersion: string): Promise { await datalyr.trackAppUpdate(previousVersion, currentVersion); } static updateAutoEventsConfig(config: Partial): void { datalyr.updateAutoEventsConfig(config); } // Standard e-commerce events static async trackAddToCart( value: number, currency = 'USD', productId?: string, productName?: string ): Promise { await datalyr.trackAddToCart(value, currency, productId, productName); } static async trackViewContent( contentId?: string, contentName?: string, contentType = 'product', value?: number, currency?: string ): Promise { await datalyr.trackViewContent(contentId, contentName, contentType, value, currency); } static async trackInitiateCheckout( value: number, currency = 'USD', numItems?: number, productIds?: string[] ): Promise { await datalyr.trackInitiateCheckout(value, currency, numItems, productIds); } static async trackCompleteRegistration(method?: string): Promise { await datalyr.trackCompleteRegistration(method); } static async trackSearch(query: string, resultIds?: string[]): Promise { await datalyr.trackSearch(query, resultIds); } static async trackLead(value?: number, currency?: string): Promise { await datalyr.trackLead(value, currency); } static async trackAddPaymentInfo(success = true): Promise { await datalyr.trackAddPaymentInfo(success); } // Platform integration methods static getDeferredAttributionData(): DeferredDeepLinkResult | null { return datalyr.getDeferredAttributionData(); } static getPlatformIntegrationStatus(): { appleSearchAds: boolean; playInstallReferrer: boolean } { return datalyr.getPlatformIntegrationStatus(); } static getAppleSearchAdsAttribution(): AppleSearchAdsAttribution | null { return datalyr.getAppleSearchAdsAttribution(); } static async updateTrackingAuthorization(enabled: boolean): Promise { await datalyr.updateTrackingAuthorization(enabled); } // Third-party integration methods static getSuperwallAttributes(): Record { return datalyr.getSuperwallAttributes(); } static getRevenueCatAttributes(): Record { return datalyr.getRevenueCatAttributes(); } } // Export default instance for backward compatibility export default datalyr;