@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
JavaScript
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