UNPKG

@shopana/ga

Version:

Type-safe Google Analytics 4 (GA4) tracking library for React and Next.js with ecommerce support, event batching, and SSR compatibility

212 lines 7.57 kB
import {} from '../types/platform'; import {} from '../types/common'; import { canTrack } from '../utils/canTrack'; import { DebugChannel } from '../utils/debugChannel'; import { sanitizeParameters, normalizeError } from '../utils/sanitize'; import { validateEventName, validateMeasurementId, validateParameters, } from '../utils/validation'; import { DEFAULT_BATCH_SIZE, DEFAULT_BATCH_TIMEOUT_MS } from './constants'; import { RetryController } from './RetryController'; import { EventQueue } from './EventQueue'; import {} from '../types/core'; export class AnalyticsClient { constructor(adapter, config, options = {}) { this.adapter = adapter; this.config = config; this.state = { status: 'idle', }; validateMeasurementId(config.measurementId); this.hooks = options.hooks ?? {}; this.logger = options.logger ?? console; this.batchSize = options.batchSize ?? config.features?.batching?.size ?? DEFAULT_BATCH_SIZE; this.batchTimeoutMs = options.batchTimeoutMs ?? config.features?.batching?.timeoutMs ?? DEFAULT_BATCH_TIMEOUT_MS; this.retryStrategy = options.retryStrategy ?? new RetryController({ maxAttempts: config.features?.retries?.enabled === false ? 0 : config.features?.retries?.maxAttempts, delayMs: config.features?.retries?.delayMs, jitterRatio: config.features?.retries?.jitterRatio, }); this.debugChannel = options.debugChannel ?? new DebugChannel(); this.eventQueue = new EventQueue({ batchSize: this.batchSize, batchTimeoutMs: this.batchTimeoutMs, batchingEnabled: this.isBatchingEnabled(), }, { send: (payload) => this.sendWithRetry(payload), onEvent: (payload) => this.hooks.onEvent?.(payload), onFlush: (count) => this.hooks.onFlush?.(count), debugChannel: this.debugChannel, handleError: (error) => this.handleError(error), }); } getDebugChannel() { return this.debugChannel; } getState() { return { status: this.state.status, disabled: Boolean(this.config.disabled), measurementId: this.config.measurementId, isReady: this.state.status === 'ready', }; } updateConfig(patch) { const nextFeatures = { ...this.config.features, ...patch.features, batching: { ...this.config.features?.batching, ...patch.features?.batching, }, retries: { ...this.config.features?.retries, ...patch.features?.retries, }, }; this.config = { ...this.config, ...patch, features: nextFeatures, }; if (patch.features?.batching) { this.batchSize = patch.features.batching.size ?? this.batchSize; this.batchTimeoutMs = patch.features.batching.timeoutMs ?? this.batchTimeoutMs; this.eventQueue.updateSettings({ batchSize: this.batchSize, batchTimeoutMs: this.batchTimeoutMs, batchingEnabled: this.isBatchingEnabled(), }); } if (patch.features?.retries) { this.retryStrategy = new RetryController({ maxAttempts: nextFeatures.retries.enabled === false ? 0 : nextFeatures.retries.maxAttempts, delayMs: nextFeatures.retries.delayMs, jitterRatio: nextFeatures.retries.jitterRatio, }); } } async init() { if (this.config.disabled) { this.logger.info('[GA] Analytics disabled; skipping init'); return; } if (this.state.status === 'destroyed') { throw new Error('Analytics client was destroyed'); } if (this.initPromise) { return this.initPromise; } if (this.state.status === 'ready') { return; } if (this.state.status !== 'idle') { return; } this.initPromise = (async () => { this.state.status = 'loading'; try { await this.adapter.load(this.config); this.state.status = 'ready'; this.hooks.onReady?.(); this.debugChannel.emit({ type: 'ready' }); await this.flush({ force: true }); } catch (error) { this.state.status = 'idle'; this.handleError(normalizeError(error)); throw error; } finally { this.initPromise = undefined; } })(); return this.initPromise; } async track(payload, options = {}) { this.assertCanTrack(); const measurementId = payload.measurementId ?? this.config.measurementId; validateMeasurementId(measurementId); validateEventName(payload.name); const sanitizedParams = sanitizeParameters({ ...(this.config.defaultParams ?? {}), ...(payload.params ?? {}), }); validateParameters(sanitizedParams); const normalized = { ...payload, measurementId, params: sanitizedParams, }; if (options.skipQueue && !canTrack(this.getState())) { throw new Error('Analytics client is not ready'); } if (!options.skipQueue || !canTrack(this.getState())) { return new Promise((resolve, reject) => { this.enqueue({ payload: normalized, resolve, reject }); if (options.forceFlush) { this.flush({ force: true }).catch(reject); } }); } try { await this.sendWithRetry(normalized); this.hooks.onEvent?.(normalized); this.debugChannel.emit({ type: 'event', payload: normalized }); } catch (error) { this.handleError(normalizeError(error)); throw error; } } async flush(options = {}) { await this.eventQueue.flush(options); } destroy() { if (this.state.status === 'destroyed') { return; } this.state.status = 'destroyed'; this.eventQueue.clear(); this.retryStrategy.abort?.(); try { this.adapter.destroy(this.config); } catch (error) { this.handleError(normalizeError(error)); } } enqueue(event) { this.eventQueue.enqueue(event); } isBatchingEnabled() { return this.config.features?.batching?.enabled ?? true; } async sendWithRetry(payload) { await this.retryStrategy.execute(async () => { await this.adapter.send(payload); }); } handleError(error) { this.logger.error('[GA]', error); this.hooks.onError?.(error); this.debugChannel.emit({ type: 'error', error }); } assertCanTrack() { if (this.state.status === 'destroyed') { throw new Error('Analytics client is destroyed'); } } } //# sourceMappingURL=AnalyticsClient.js.map