uni-analytics-sdk
Version:
A universal SDK for analytics and logging.
550 lines (537 loc) • 20.6 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();
}
async processEventsNode(events) {
if (events.length === 0)
return;
const { createHash } = await import('crypto');
const userId = events.reduce((id, event) => event.userId || id, 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');
const userProperties = this.extractUserProperties(events);
const trackEvents = events
.filter(event => event.type === 'track' && event.eventName)
.map(async (event) => await this.mapEventToMeasurementProtocol(event));
if (trackEvents.length === 0)
return;
const payload = {
client_id: clientId,
user_id: userId,
timestamp_micros: (Date.now() * 1000).toString(),
user_properties: userProperties,
events: trackEvents,
// Deprecated but useful as a fallback. For full compliance, use the 'consent' object.
non_personalized_ads: false,
};
// IMPROVEMENT: Use EU-specific endpoint if configured.
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) {
// The debug endpoint always returns JSON, even on success.
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));
}
}
}
catch (error) {
console.error('[UniversalSDK] Failed to send events to GA4 Measurement Protocol:', 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);
}
}
/**
* @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.js.map
;