UNPKG

ghost

Version:

The professional publishing platform

247 lines (209 loc) 8.72 kB
import { getCountryForTimezone } from 'countries-and-timezones'; import { parseReferrer } from '../utils/url-attribution'; import { processPayload } from '../utils/privacy'; import { BrowserService } from './browser-service'; /** * @typedef {Object} TinybirdApi * @property {function} trackEvent - Function to track custom events * @property {function} _trackPageHit - Internal function to track page hits */ // Configuration constants const DEFAULT_DATASOURCE = 'analytics_events'; // Runtime configuration let config = { host: null, token: null, domain: null, datasource: DEFAULT_DATASOURCE, stringifyPayload: true, globalAttributes: {} }; export class GhostStats { constructor(browserService = new BrowserService()) { this.browser = browserService; this.isListenersAttached = false; } get isTestEnv() { return this.browser.isTestEnvironment(); } initConfig() { const currentScript = this.browser.getCurrentScript(); if (!currentScript) { return false; } // Get required parameters config.host = currentScript.getAttribute('data-host'); config.token = currentScript.getAttribute('data-token') || null; config.domain = currentScript.getAttribute('data-domain'); // Get optional parameters config.datasource = currentScript.getAttribute('data-datasource') || config.datasource; config.stringifyPayload = currentScript.getAttribute('data-stringify-payload') !== 'false'; // Get global attributes for (const attr of currentScript.attributes) { if (attr.name.startsWith('tb_')) { config.globalAttributes[attr.name.slice(3)] = attr.value; } } // Validate required configuration return !!(config.host); } generateUUID() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } // Fallback to a simple UUID generator return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } async trackEvent(name, payload) { try { // Check if we have required configuration // Token is optional — if using the analytics service, it will set the token itself if (!config.host) { throw new Error('Missing required configuration (host)'); } let url = `${config.host}?name=${encodeURIComponent(config.datasource)}`; if (config.token) { url += `&token=${encodeURIComponent(config.token)}`; } payload.event_id = this.generateUUID(); // Process the payload, masking sensitive data const processedPayload = processPayload(payload, config.globalAttributes, config.stringifyPayload); // Prepare request data const data = { timestamp: new Date().toISOString(), action: name, version: '1', payload: processedPayload, }; // Use fetch with timeout for better error handling const controller = new AbortController(); const timeoutId = this.browser.setTimeout(() => controller.abort(), 5000); // 5 second timeout const headers = { 'Content-Type': 'application/json', }; if (config.globalAttributes?.site_uuid) { headers['x-site-uuid'] = config.globalAttributes.site_uuid; } const response = await this.browser.fetch(url, { method: 'POST', headers, body: JSON.stringify(data), signal: controller.signal }); this.browser.clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } return response; } catch (error) { // Silently fail for tracking errors // Only log in non-production environments const location = this.browser.getLocation(); if (location?.hostname === 'localhost' || location?.hostname === '127.0.0.1') { console.error('Ghost Stats error:', error); } return null; } } getLocationInfo() { try { // Get timezone from browser const timezone = this.browser.getTimezone(); // Get country from timezone using countries-and-timezones const countryData = timezone ? getCountryForTimezone(timezone) : null; // Get locale, falling back gracefully const navigator = this.browser.getNavigator(); const locale = navigator?.languages?.[0] || navigator?.language || 'en'; return { country: countryData ? countryData.id : null, // Returns country code locale }; } catch (error) { return { country: null, locale: 'en' }; } } trackPageHit() { // Skip tracking in test environments if (this.isTestEnv) { return; } // Get location information const { country, locale } = this.getLocationInfo(); const navigator = this.browser.getNavigator(); const location = this.browser.getLocation(); // Wait a bit for SPA routers this.browser.setTimeout(() => { this.trackEvent('page_hit', { 'user-agent': navigator?.userAgent, locale, location: country, parsedReferrer: parseReferrer(location?.href), // this sends an object with source, medium, and url pathname: location?.pathname, href: location?.href, }); }, 300); } setupEventListeners() { if (this.isListenersAttached) { return; } // Track history navigation this.browser.addEventListener('window', 'hashchange', () => this.trackPageHit()); // Handle SPA navigation this.browser.wrapHistoryMethod('pushState', () => this.trackPageHit()); this.browser.addEventListener('window', 'popstate', () => this.trackPageHit()); // Handle visibility changes for prerendering if (this.browser.getVisibilityState() !== 'hidden') { // Page is initially visible, track immediately this.trackPageHit(); } else { // Page is hidden (possibly prerendering), wait for visibility const onVisibilityChange = () => { if (this.browser.getVisibilityState() === 'visible') { this.trackPageHit(); this.browser.removeEventListener('document', 'visibilitychange', onVisibilityChange); } }; this.browser.addEventListener('document', 'visibilitychange', onVisibilityChange); } this.isListenersAttached = true; } init() { // Skip in test environments if (this.isTestEnv) { return false; } // Initialize configuration const configInitialized = this.initConfig(); if (!configInitialized) { console.warn('Ghost Stats: Missing required configuration'); return false; } // Expose global API if (this.browser.window) { this.browser.window.Tinybird = { trackEvent: (name, payload) => this.trackEvent(name, payload), _trackPageHit: () => this.trackPageHit() }; } this.setupEventListeners(); return true; } } // Create and initialize instance for auto-initialization const ghostStats = new GhostStats(); ghostStats.init(); // Export bound methods to maintain this context export const isTestEnv = () => ghostStats.isTestEnv; export const initConfig = ghostStats.initConfig.bind(ghostStats); export const trackEvent = ghostStats.trackEvent.bind(ghostStats); export const getLocationInfo = ghostStats.getLocationInfo.bind(ghostStats); export const trackPageHit = ghostStats.trackPageHit.bind(ghostStats); export const setupEventListeners = ghostStats.setupEventListeners.bind(ghostStats); export const init = ghostStats.init.bind(ghostStats); // Also export the instance for testing export const ghostStatsInstance = ghostStats;