uni-analytics-sdk
Version:
A universal SDK for analytics and logging.
656 lines (643 loc) • 26.1 kB
JavaScript
// 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
;