UNPKG

uni-analytics-sdk

Version:

A universal SDK for analytics and logging.

656 lines (643 loc) 26.1 kB
'use strict'; // export class UniversalSDK { // private queue: SDKEvent[] = []; // private providers: Provider[] = []; // constructor() { // console.log("Universal SDK Initialized."); // } // public init() { // // Initialization logic will go here // } // public track(eventName: string, properties?: Record<string, any>) { // console.log(`Tracking event: ${eventName}`, properties); // // Queueing logic will go here // } // public identify(userId: string, traits?: Record<string, any>) { // console.log(`Identifying user: ${userId}`, traits); // // Queueing logic will go here // } // } /** * @fileoverview This file contains the core implementation of the Universal Analytics and Logging SDK. * It defines the main SDK class, provider interfaces, and the logic for event queueing, * batching, and dispatching. */ // ================================================================================================= // SECTION: Core SDK Implementation // ================================================================================================= /** * The main class for the Universal SDK. It orchestrates providers and manages the event lifecycle. */ /** * The core of the Universal SDK. It manages the event queue, provider lifecycle, * and orchestrates the sending of data. */ class UniversalSDK { constructor(config) { this.queue = []; this.providers = []; this.flushInterval = null; this.isInitialized = false; this.config = { queueSize: 20, flushInterval: 10000, // 10 seconds debug: false, ...config, }; } /** * Initializes the SDK and sets up all configured providers. * This must be called before any tracking or identification can occur. */ async init() { if (this.isInitialized) { if (this.config.debug) { console.warn('SDK already initialized.'); } return; } if (this.config.debug) { console.log('SDK Initializing with config:', this.config); } this.providers = this.config.providers; const setupPromises = this.providers.map(provider => { const providerConfig = this.config.providerConfigs?.[provider.name] || {}; return provider.setup(providerConfig).catch((error) => { console.error(`[SDK] Failed to setup provider: ${provider.name}`, error); }); }); await Promise.all(setupPromises); this.flushInterval = setInterval(() => this.flush(), this.config.flushInterval); this.isInitialized = true; if (this.config.debug) { console.log('SDK Initialized successfully.'); } } /** * Tracks a custom event. * @param eventName The name of the event. * @param properties An object of key-value pairs for additional data. */ track(eventName, properties) { this.enqueue({ type: 'track', eventName, properties, timestamp: Date.now(), }); } /** * Identifies a user and associates traits with them. * @param userId A unique identifier for the user. * @param traits An object of user properties. */ identify(userId, traits) { this.enqueue({ type: 'identify', userId, traits, timestamp: Date.now(), }); } /** * Captures a manually thrown error or exception. * @param error The error object. * @param context Additional context for the error. */ captureException(error, context) { this.enqueue({ type: 'captureException', error, context, timestamp: Date.now(), }); } /** * Captures a log message. * @param message The message to log. * @param level The severity level of the message. */ captureMessage(message, level = 'info') { this.enqueue({ type: 'captureMessage', message, level, timestamp: Date.now(), }); } /** * Adds an event to the processing queue. * @param event The SDKEvent to enqueue. */ enqueue(event) { if (!this.isInitialized) { console.error('[SDK] SDK not initialized. Call .init() first.'); return; } const completeEvent = { timestamp: Date.now(), ...event, }; this.queue.push(completeEvent); if (this.config.debug) { console.log('[SDK] Event enqueued:', completeEvent); console.log(`[SDK] Queue size: ${this.queue.length}`); } if (this.queue.length >= this.config.queueSize) { this.flush(); } } /** * Sends all events in the queue to the configured providers. */ async flush() { if (this.queue.length === 0) { return; } const eventsToProcess = [...this.queue]; this.queue = []; if (this.config.debug) { console.log(`[SDK] Flushing ${eventsToProcess.length} events...`); } const processingPromises = this.providers.map(provider => { return provider.processEvents(eventsToProcess).catch((error) => { console.error(`[SDK] Error processing events for provider: ${provider.name}`, error); }); }); await Promise.all(processingPromises); } /** * Cleans up resources, like the flush interval. */ shutdown() { if (this.flushInterval) { clearInterval(this.flushInterval); } this.flush(); // Final flush before shutting down } } // ================================================================================================= // SECTION: Example Usage (for demonstration) // ================================================================================================= // This part would be in a separate file in a real application. /* // 1. Define a mock provider for demonstration class ConsoleLogProvider implements Provider { name = 'ConsoleLogProvider'; async setup(config: Record<string, any>): Promise<void> { console.log(`[ConsoleLogProvider] Setup with config:`, config); } async processEvents(events: SDKEvent[]): Promise<void> { console.log(`[ConsoleLogProvider] Processing ${events.length} events:`); events.forEach(event => { console.log(JSON.stringify(event, null, 2)); }); } } // 2. Configure and initialize the SDK const sdk = new UniversalSDK({ providers: [new ConsoleLogProvider()], providerConfigs: { ConsoleLogProvider: { apiKey: 'test-key' } }, debug: true, maxQueueSize: 5, // Flush after 5 events for demo purposes }); sdk.init().then(() => { console.log("--- SDK Ready ---"); // 3. Use the SDK sdk.identify('user-123', { name: 'John Doe', plan: 'premium' }); sdk.track('Page Viewed', { page: '/home' }); sdk.track('Button Clicked', { button: 'signup' }); sdk.captureMessage('This is an informational message.', 'info'); sdk.track('Item Added', { item: 'sdk-core' }); // This should trigger a flush try { throw new Error("Something went wrong!"); } catch (e) { if (e instanceof Error) { sdk.captureException(e, { details: 'Caught in example usage' }); } } // The SDK will also flush automatically based on the flushInterval }); // Make sure to handle shutdown gracefully in a real app // window.addEventListener('beforeunload', () => sdk.shutdown()); */ /** * @fileoverview This file contains the implementation of the GoogleAnalyticsProvider. * It handles sending analytics and error data to Google Analytics 4 (GA4) from both * client-side (browser) and server-side (Node.js) environments. */ // import { createHash } from 'crypto'; // Node.js crypto module for hashing /** * A utility function to flatten a nested object into a single-level object. * GA4's Measurement Protocol does not support nested objects in event properties, * except for specific, documented cases like the 'items' array in e-commerce events. * @param obj The object to flatten. * @returns A flattened object. */ function flattenObject(obj, parentKey = '', result = {}) { for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { const newKey = parentKey ? `${parentKey}_${key}` : key; const value = obj[key]; // GA4 e-commerce events require a specific 'items' array of objects. Do NOT flatten it. if (key === 'items' && Array.isArray(value)) { result[key] = value; continue; } if (typeof value === 'object' && value !== null && !Array.isArray(value)) { flattenObject(value, newKey, result); } else { // GA4 has a 100-character limit for parameter values. Truncate if necessary. result[newKey] = typeof value === 'string' ? value.substring(0, 100) : value; } } } return result; } // ================================================================================================= // SECTION: Google Analytics Provider Implementation // ================================================================================================= class GoogleAnalyticsProvider { constructor() { this.name = 'GoogleAnalytics'; this.isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; this.lastUserId = null; } /** * Sets up the GA4 provider. In the browser, it loads the gtag.js script. * In Node.js, it verifies the necessary configuration. * @param {GoogleAnalyticsProviderConfig} config - The configuration for this provider. */ setup(config) { this.config = { debug: false, region: 'middle-east', ...config }; if (!config.measurementId) { return Promise.reject(new Error('GoogleAnalyticsProvider: measurementId is required.')); } if (this.isBrowser) { return this.setupBrowser(); } else { return this.setupNode(); } } /** * Processes a batch of events and sends them to GA4. * @param {SDKEvent[]} events - An array of events from the SDK core. */ async processEvents(events) { if (this.isBrowser) { this.processEventsBrowser(events); } else { await this.processEventsNode(events); } } // --- Private Browser-Specific Methods --- setupBrowser() { return new Promise((resolve, reject) => { const measurementId = this.config.measurementId; const scriptId = 'ga-gtag-script'; if (document.getElementById(scriptId)) { return resolve(); } const script = document.createElement('script'); script.id = scriptId; script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`; script.async = true; script.onload = () => { window.dataLayer = window.dataLayer || []; window.gtag = function () { window.dataLayer.push(arguments); }; window.gtag('js', new Date()); // Configure GA4. The 'send_page_view' can be controlled. const pageViews = this.config.automaticPageViews !== false; window.gtag('config', measurementId, { 'send_page_view': pageViews }); console.log('[UniversalSDK] GoogleAnalyticsProvider setup complete (Browser).'); resolve(); }; script.onerror = () => { reject(new Error('GoogleAnalyticsProvider: Failed to load gtag.js script.')); }; document.head.appendChild(script); }); } processEventsBrowser(events) { if (!window.gtag) return; events.forEach(event => { switch (event.type) { case 'identify': if (event.userId) { // Set the user_id for all subsequent events window.gtag('config', this.config.measurementId, { 'user_id': event.userId }); } if (event.traits) { // Set user properties window.gtag('set', 'user_properties', flattenObject(event.traits)); } break; case 'track': if (event.eventName) { window.gtag('event', event.eventName, flattenObject(event.properties)); } break; case 'captureException': window.gtag('event', 'exception', { 'description': event.error?.message, 'fatal': event.context?.level === 'critical', ...flattenObject(event.context || {}) }); break; case 'captureMessage': // GA4 doesn't have a direct equivalent to `captureMessage`, // so we send it as a custom event. window.gtag('event', 'log_message', { 'message': event.message, 'level': event.level, ...flattenObject(event.context || {}) }); break; } }); } // --- Private Node.js-Specific Methods --- setupNode() { if (!this.config.apiSecret) { return Promise.reject(new Error('GoogleAnalyticsProvider: apiSecret is required for Node.js environment.')); } console.log('[UniversalSDK] GoogleAnalyticsProvider setup complete (Node.js).'); return Promise.resolve(); } // private async processEventsNode(events: SDKEvent[]): Promise<void> { // if (events.length === 0) return; // const { createHash } = await import('crypto'); // const userId = events.reduce((id, event) => event.userId || id, null as string | null); // if (!userId) { // console.warn('[UniversalSDK] GA4 events skipped: no userId found for server-side tracking.'); // return; // } // const clientId = createHash('sha256').update(userId).digest('hex'); // // --- START: More Robust Event Processing Logic --- // const userPropertiesTraits: Record<string, any> = {}; // const trackEventsPayload: any[] = []; // for (const event of events) { // if (event.type === 'identify' && event.traits) { // // Collect all user traits from identify calls // Object.assign(userPropertiesTraits, event.traits); // } else if (event.type === 'track' && event.eventName) { // // Explicitly process and push valid track events // const mappedEvent = await this.mapEventToMeasurementProtocol(event); // trackEventsPayload.push(mappedEvent); // } // } // // --- END: More Robust Event Processing Logic --- // const userProperties = await this.sanitizeAndFormatUserProperties(userPropertiesTraits); // if (trackEventsPayload.length === 0 && Object.keys(userProperties).length === 0) return; // const payload: any = { // client_id: clientId, // user_id: userId, // timestamp_micros: (Date.now() * 1000).toString(), // non_personalized_ads: false, // }; // if (Object.keys(userProperties).length > 0) { // payload.user_properties = userProperties; // } // if (trackEventsPayload.length > 0) { // payload.events = trackEventsPayload; // } // const domain = this.config.region === 'eu' ? 'region1.google-analytics.com' : 'www.google-analytics.com'; // const url = this.config.debug // ? `https://${domain}/debug/mp/collect?measurement_id=${this.config.measurementId}&api_secret=${this.config.apiSecret}` // : `https://${domain}/mp/collect?measurement_id=${this.config.measurementId}&api_secret=${this.config.apiSecret}`; // try { // const response = await fetch(url, { // method: 'POST', // headers: { 'Content-Type': 'application/json' }, // body: JSON.stringify(payload), // }); // if (this.config.debug || !response.ok) { // const responseBody = await response.json(); // if (!response.ok) { // console.error(`[UniversalSDK] GA4 Measurement Protocol Error (${response.status}):`, JSON.stringify(responseBody, null, 2)); // } else { // console.log('[UniversalSDK] GA4 Debug Response:', JSON.stringify(responseBody, null, 2)); // } // } else if (response.ok && !this.config.debug) { // console.log(`[UniversalSDK] Successfully sent ${trackEventsPayload.length} event(s) and user properties to GA4.`); // } // } catch (error: any) { // console.error('[UniversalSDK] Failed to send events to GA4 Measurement Protocol:', error.message); // } // } async processEventsNode(events) { if (events.length === 0) return; // --- START: Corrected Stateful Grouping Logic --- const eventsByUser = new Map(); // Initialize the current user with the state from the previous batch. let currentUserIdInBatch = this.lastUserId; for (const event of events) { // If we find an identify event, we update the current user context // for all subsequent events in *this* batch. if (event.type === 'identify' && event.userId) { currentUserIdInBatch = event.userId; } // Determine the event's user ID, falling back to the current context. const userId = event.userId || currentUserIdInBatch; if (userId) { if (!eventsByUser.has(userId)) { eventsByUser.set(userId, []); } eventsByUser.get(userId).push(event); } } // Persist the final user ID seen in this batch for the next one. if (currentUserIdInBatch) { this.lastUserId = currentUserIdInBatch; } // --- END: Corrected Stateful Grouping Logic --- if (eventsByUser.size === 0) { console.warn('[UniversalSDK] GA4 events skipped: no userId could be determined for the batch.'); return; } // Create and send a separate request for each user in the batch. for (const [userId, userEvents] of eventsByUser.entries()) { await this.sendPayloadForUser(userId, userEvents); } } async sendPayloadForUser(userId, events) { const { createHash } = await import('crypto'); const clientId = createHash('sha256').update(userId).digest('hex'); const combinedTraits = events .filter(e => e.type === 'identify' && e.traits) .reduce((acc, e) => ({ ...acc, ...e.traits }), {}); const trackEventsPayload = await Promise.all(events .filter(e => e.type === 'track' && e.eventName) .map(e => this.mapEventToMeasurementProtocol({ ...e, userId }))); const userProperties = await this.sanitizeAndFormatUserProperties(combinedTraits); if (trackEventsPayload.length === 0 && Object.keys(userProperties).length === 0) return; const payload = { client_id: clientId, user_id: userId, timestamp_micros: (Date.now() * 1000).toString(), non_personalized_ads: false, }; if (Object.keys(userProperties).length > 0) payload.user_properties = userProperties; if (trackEventsPayload.length > 0) payload.events = trackEventsPayload; const domain = this.config.region === 'eu' ? 'region1.google-analytics.com' : 'www.google-analytics.com'; const url = this.config.debug ? `https://${domain}/debug/mp/collect?measurement_id=${this.config.measurementId}&api_secret=${this.config.apiSecret}` : `https://${domain}/mp/collect?measurement_id=${this.config.measurementId}&api_secret=${this.config.apiSecret}`; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (this.config.debug || !response.ok) { const responseBody = await response.json(); if (!response.ok) { console.error(`[UniversalSDK] GA4 Error for user ${userId} (${response.status}):`, JSON.stringify(responseBody, null, 2)); } else { console.log(`[UniversalSDK] GA4 Debug Response for user ${userId}:`, JSON.stringify(responseBody, null, 2)); } } else if (response.ok && !this.config.debug) { console.log(`[UniversalSDK] Sent ${trackEventsPayload.length} event(s) for user ${userId} to GA4.`); } } catch (error) { console.error(`[UniversalSDK] Failed to send GA4 events for user ${userId}:`, error.message); } } async extractUserProperties(events) { const traits = events .filter(event => event.type === 'identify' && event.traits) .reduce((acc, event) => ({ ...acc, ...event.traits }), {}); const flattenedTraits = flattenObject(traits); const sanitizedProperties = {}; for (const key in flattenedTraits) { const value = flattenedTraits[key]; // CRITICAL FIX: Skip any properties that have null, undefined, or empty array values. if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0)) { sanitizedProperties[key] = { value }; } } return sanitizedProperties; } async mapEventToMeasurementProtocol(event) { // Developer Note: Event names and parameter names have reserved prefixes and names. // Do not use reserved names like 'session_start' or 'user_engagement' for custom events. const name = event.eventName; const params = flattenObject({ ...event.properties, ...event.context }); try { // Per GA4 docs, engagement_time_msec and session_id are highly recommended for // events to appear in standard reports, including Realtime. params.engagement_time_msec = params.engagement_time_msec || '100'; params.session_id = params.session_id || await this.generateStableSessionId(event.userId); } catch (error) { console.log("Error: ", error); } return { name, params }; } async generateStableSessionId(userId) { const { createHash } = await import('crypto'); // Create a session ID that is stable for a period (e.g., a day) // This helps group events from the same user on the same day into a session. const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD return createHash('sha256').update(userId + today).digest('hex').substring(0, 10); } async sanitizeAndFormatUserProperties(traits) { const flattenedTraits = flattenObject(traits); const sanitizedProperties = {}; for (const key in flattenedTraits) { const value = flattenedTraits[key]; if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0)) { sanitizedProperties[key] = { value }; } } return sanitizedProperties; } } /** * @fileoverview This file contains strongly-typed event creators for standard * Google Analytics 4 events, particularly for e-commerce. Using these templates * ensures that the data sent to GA4 via the SDK's `track` method is valid and * conforms to Google's official schema. */ /** * A collection of functions to create standardized GA4 event objects. * These can be passed directly to the `sdk.track()` method. */ const ga4 = { /** * Creates a valid 'purchase' event payload. */ purchase: (params) => ({ eventName: 'purchase', properties: params, }), /** * Creates a valid 'refund' event payload. */ refund: (params) => ({ eventName: 'refund', properties: params, }), /** * Creates a valid 'add_to_cart' event payload. */ addToCart: (params) => ({ eventName: 'add_to_cart', properties: params, }), /** * Creates a valid 'begin_checkout' event payload. */ beginCheckout: (params) => ({ eventName: 'begin_checkout', properties: params, }), /** * Creates a valid 'view_item' event payload. */ viewItem: (params) => ({ eventName: 'view_item', properties: params, }), /** * Creates a valid 'generate_lead' event payload. */ generateLead: (params) => ({ eventName: 'generate_lead', properties: params, }), /** * Creates a valid 'sign_up' event payload. */ signUp: (params) => ({ eventName: 'sign_up', properties: params, }), /** * Creates a valid 'login' event payload. */ login: (params) => ({ eventName: 'login', properties: params, }), }; exports.GoogleAnalyticsProvider = GoogleAnalyticsProvider; exports.UniversalSDK = UniversalSDK; exports.ga4 = ga4; //# sourceMappingURL=index.cjs.map