uni-analytics-sdk
Version:
A universal SDK for analytics and logging.
406 lines (340 loc) • 16.3 kB
text/typescript
/**
* @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;
}
}