UNPKG

uni-analytics-sdk

Version:

A universal SDK for analytics and logging.

406 lines (340 loc) 16.3 kB
/** * @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 { SDKEvent, Provider, GoogleAnalyticsProviderConfig } from '@/interfaces'; // 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: any, parentKey = '', result: { [key: string]: any } = {}): { [key: string]: any } { 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 // ================================================================================================= export class GoogleAnalyticsProvider implements Provider { public name = 'GoogleAnalytics'; private config!: GoogleAnalyticsProviderConfig; private isBrowser: boolean = typeof window !== 'undefined' && typeof document !== 'undefined'; private lastUserId: string | null = 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. */ public setup(config: GoogleAnalyticsProviderConfig): Promise<void> { 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. */ public async processEvents(events: SDKEvent[]): Promise<void> { if (this.isBrowser) { this.processEventsBrowser(events); } else { await this.processEventsNode(events); } } // --- Private Browser-Specific Methods --- private setupBrowser(): Promise<void> { 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); }); } private processEventsBrowser(events: SDKEvent[]): void { 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 --- private setupNode(): Promise<void> { 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); // } // } private async processEventsNode(events: SDKEvent[]): Promise<void> { if (events.length === 0) return; // --- START: Corrected Stateful Grouping Logic --- const eventsByUser = new Map<string, SDKEvent[]>(); // 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); } } private async sendPayloadForUser(userId: string, events: SDKEvent[]): Promise<void> { 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: 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 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: any) { console.error(`[UniversalSDK] Failed to send GA4 events for user ${userId}:`, error.message); } } private async extractUserProperties(events: SDKEvent[]): Promise<Record<string, { value: any }>> { const traits = events .filter(event => event.type === 'identify' && event.traits) .reduce((acc, event) => ({ ...acc, ...event.traits }), {}); const flattenedTraits = flattenObject(traits); const sanitizedProperties: Record<string, { value: any }> = {}; 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; } private async mapEventToMeasurementProtocol(event: SDKEvent): Promise<{ name: string, params: Record<string, any> }> { // 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 }; } private async generateStableSessionId(userId: string): Promise<string> { 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); } private async sanitizeAndFormatUserProperties(traits: Record<string, any>): Promise<Record<string, { value: any }>> { const flattenedTraits = flattenObject(traits); const sanitizedProperties: Record<string, { value: any }> = {}; 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; } }