301 lines
8.7 KiB
TypeScript
301 lines
8.7 KiB
TypeScript
|
|
import { trace } from '@opentelemetry/api';
|
||
|
|
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
|
||
|
|
|
||
|
|
// Types and Interfaces
|
||
|
|
interface AnalyticsMetadata {
|
||
|
|
[key: string]: string | number | boolean | undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Constants
|
||
|
|
const ANALYTICS_ENABLED_KEY = 'analyticsEnabled';
|
||
|
|
const LOCATION_CONSENT_KEY = 'locationTrackingConsent';
|
||
|
|
const SESSION_ID_KEY = 'sessionId';
|
||
|
|
const USER_ID_KEY = 'userId';
|
||
|
|
|
||
|
|
// Generate a unique session ID
|
||
|
|
const generateSessionId = (): string => {
|
||
|
|
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Get current user ID (implement based on your auth system)
|
||
|
|
export const getCurrentUserId = (): string | null => {
|
||
|
|
// This is a placeholder - implement based on your authentication system
|
||
|
|
// For example, you might get this from localStorage, cookies, or context
|
||
|
|
return localStorage.getItem(USER_ID_KEY) || sessionStorage.getItem(USER_ID_KEY) || null;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Track page view
|
||
|
|
export const trackPageView = (pathname: string): void => {
|
||
|
|
// Check if analytics are enabled
|
||
|
|
if (!isAnalyticsEnabled()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const tracer = trace.getTracer('bakery-frontend');
|
||
|
|
const user_id = getCurrentUserId();
|
||
|
|
|
||
|
|
const span = tracer.startSpan('page_view', {
|
||
|
|
attributes: {
|
||
|
|
[ATTR_HTTP_ROUTE]: pathname,
|
||
|
|
'user.id': user_id || 'anonymous',
|
||
|
|
'page.path': pathname,
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// End the span immediately for page views
|
||
|
|
span.end();
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to track page view:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Check if analytics are enabled
|
||
|
|
export const isAnalyticsEnabled = (): boolean => {
|
||
|
|
return localStorage.getItem(ANALYTICS_ENABLED_KEY) !== 'false';
|
||
|
|
};
|
||
|
|
|
||
|
|
// Enable or disable analytics
|
||
|
|
export const setAnalyticsEnabled = (enabled: boolean): void => {
|
||
|
|
localStorage.setItem(ANALYTICS_ENABLED_KEY, enabled.toString());
|
||
|
|
};
|
||
|
|
|
||
|
|
// Check if location tracking consent is granted
|
||
|
|
export const isLocationTrackingConsentGranted = (): boolean => {
|
||
|
|
return localStorage.getItem(LOCATION_CONSENT_KEY) === 'granted';
|
||
|
|
};
|
||
|
|
|
||
|
|
// Set location tracking consent
|
||
|
|
export const setLocationTrackingConsent = (granted: boolean): void => {
|
||
|
|
localStorage.setItem(LOCATION_CONSENT_KEY, granted ? 'granted' : 'denied');
|
||
|
|
};
|
||
|
|
|
||
|
|
// Track user session
|
||
|
|
export const trackSession = (): (() => void) => {
|
||
|
|
// Check if analytics are enabled
|
||
|
|
if (!isAnalyticsEnabled()) {
|
||
|
|
console.log('Analytics disabled by user preference');
|
||
|
|
return () => {}; // Return no-op cleanup function
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const tracer = trace.getTracer('bakery-frontend');
|
||
|
|
const sessionId = generateSessionId();
|
||
|
|
const userId = getCurrentUserId();
|
||
|
|
|
||
|
|
const span = tracer.startSpan('user_session', {
|
||
|
|
attributes: {
|
||
|
|
'session.id': sessionId,
|
||
|
|
'user.id': userId || 'anonymous',
|
||
|
|
'browser.user_agent': navigator.userAgent,
|
||
|
|
'screen.width': window.screen.width.toString(),
|
||
|
|
'screen.height': window.screen.height.toString(),
|
||
|
|
'device.type': /mobile|tablet|ipad|iphone|ipod|android|silk/i.test(navigator.userAgent) ? 'mobile' : 'desktop'
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Store session ID in sessionStorage for later use
|
||
|
|
sessionStorage.setItem(SESSION_ID_KEY, sessionId);
|
||
|
|
|
||
|
|
// End span when session ends
|
||
|
|
const handleBeforeUnload = () => {
|
||
|
|
span.end();
|
||
|
|
};
|
||
|
|
|
||
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||
|
|
|
||
|
|
// Clean up event listener when needed
|
||
|
|
return () => {
|
||
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to track session:', error);
|
||
|
|
return () => {}; // Return no-op cleanup function
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Track user action
|
||
|
|
export const trackUserAction = (action: string, metadata?: AnalyticsMetadata): void => {
|
||
|
|
// Check if analytics are enabled
|
||
|
|
if (!isAnalyticsEnabled()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const tracer = trace.getTracer('bakery-frontend');
|
||
|
|
const userId = getCurrentUserId();
|
||
|
|
|
||
|
|
const span = tracer.startSpan('user_action', {
|
||
|
|
attributes: {
|
||
|
|
'user.action': action,
|
||
|
|
'user.id': userId || 'anonymous',
|
||
|
|
...metadata
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
span.end();
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to track user action:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Track user location (with consent)
|
||
|
|
export const trackUserLocation = async (): Promise<void> => {
|
||
|
|
// Check if analytics are enabled
|
||
|
|
if (!isAnalyticsEnabled()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if location tracking consent is granted
|
||
|
|
if (!isLocationTrackingConsentGranted()) {
|
||
|
|
console.log('Location tracking consent not granted');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||
|
|
if (!navigator.geolocation) {
|
||
|
|
reject(new Error('Geolocation not supported'));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||
|
|
enableHighAccuracy: false,
|
||
|
|
timeout: 10000,
|
||
|
|
maximumAge: 300000 // 5 minutes
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const tracer = trace.getTracer('bakery-frontend');
|
||
|
|
const userId = getCurrentUserId();
|
||
|
|
|
||
|
|
const span = tracer.startSpan('user_location', {
|
||
|
|
attributes: {
|
||
|
|
'user.id': userId || 'anonymous',
|
||
|
|
'location.latitude': position.coords.latitude,
|
||
|
|
'location.longitude': position.coords.longitude,
|
||
|
|
'location.accuracy': position.coords.accuracy,
|
||
|
|
'location.altitude': position.coords.altitude ?? undefined,
|
||
|
|
'location.speed': position.coords.speed ?? undefined,
|
||
|
|
'location.heading': position.coords.heading ?? undefined
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
span.end();
|
||
|
|
} catch (error) {
|
||
|
|
console.log('Location access denied or unavailable:', error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Initialize analytics tracking
|
||
|
|
export const initializeAnalytics = (): (() => void) => {
|
||
|
|
// Track initial session
|
||
|
|
const cleanupSession = trackSession();
|
||
|
|
|
||
|
|
// Track initial page view
|
||
|
|
trackPageView(window.location.pathname);
|
||
|
|
|
||
|
|
// Listen for route changes (for SPA navigation)
|
||
|
|
let previousUrl = window.location.href;
|
||
|
|
|
||
|
|
// For hash-based routing
|
||
|
|
const handleHashChange = () => {
|
||
|
|
if (window.location.href !== previousUrl) {
|
||
|
|
trackPageView(window.location.pathname + window.location.search);
|
||
|
|
previousUrl = window.location.href;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// For history API-based routing (most common in React apps)
|
||
|
|
// Use proper typing for history state methods
|
||
|
|
const originalPushState = history.pushState.bind(history);
|
||
|
|
const handlePushState = function (
|
||
|
|
this: History,
|
||
|
|
data: unknown,
|
||
|
|
unused: string,
|
||
|
|
url?: string | URL | null
|
||
|
|
) {
|
||
|
|
originalPushState(data, unused, url);
|
||
|
|
setTimeout(() => {
|
||
|
|
if (window.location.href !== previousUrl) {
|
||
|
|
trackPageView(window.location.pathname + window.location.search);
|
||
|
|
previousUrl = window.location.href;
|
||
|
|
}
|
||
|
|
}, 0);
|
||
|
|
};
|
||
|
|
|
||
|
|
const originalReplaceState = history.replaceState.bind(history);
|
||
|
|
const handleReplaceState = function (
|
||
|
|
this: History,
|
||
|
|
data: unknown,
|
||
|
|
unused: string,
|
||
|
|
url?: string | URL | null
|
||
|
|
) {
|
||
|
|
originalReplaceState(data, unused, url);
|
||
|
|
setTimeout(() => {
|
||
|
|
if (window.location.href !== previousUrl) {
|
||
|
|
trackPageView(window.location.pathname + window.location.search);
|
||
|
|
previousUrl = window.location.href;
|
||
|
|
}
|
||
|
|
}, 0);
|
||
|
|
};
|
||
|
|
|
||
|
|
// Override history methods
|
||
|
|
history.pushState = handlePushState;
|
||
|
|
history.replaceState = handleReplaceState;
|
||
|
|
|
||
|
|
// Add event listeners
|
||
|
|
window.addEventListener('hashchange', handleHashChange);
|
||
|
|
|
||
|
|
// Track user consent for location if needed
|
||
|
|
if (isLocationTrackingConsentGranted()) {
|
||
|
|
trackUserLocation();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Return cleanup function
|
||
|
|
return () => {
|
||
|
|
// Restore original history methods
|
||
|
|
history.pushState = originalPushState;
|
||
|
|
history.replaceState = originalReplaceState;
|
||
|
|
|
||
|
|
// Remove event listeners
|
||
|
|
window.removeEventListener('hashchange', handleHashChange);
|
||
|
|
|
||
|
|
// Clean up session tracking
|
||
|
|
cleanupSession();
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
// Function to track custom metrics using OpenTelemetry spans
|
||
|
|
export const trackCustomMetric = (
|
||
|
|
name: string,
|
||
|
|
value: number,
|
||
|
|
attributes?: Record<string, string>
|
||
|
|
): void => {
|
||
|
|
// Check if analytics are enabled
|
||
|
|
if (!isAnalyticsEnabled()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Record metric as a span with the value as an attribute
|
||
|
|
// This approach works well for browser-based metrics since
|
||
|
|
// the OpenTelemetry metrics API in browsers sends to the same collector
|
||
|
|
const tracer = trace.getTracer('bakery-frontend');
|
||
|
|
const userId = getCurrentUserId();
|
||
|
|
|
||
|
|
const span = tracer.startSpan('custom_metric', {
|
||
|
|
attributes: {
|
||
|
|
'metric.name': name,
|
||
|
|
'metric.value': value,
|
||
|
|
'user.id': userId || 'anonymous',
|
||
|
|
...attributes
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
span.end();
|
||
|
|
} catch (error) {
|
||
|
|
// Log error but don't fail - metrics are non-critical
|
||
|
|
console.warn('Failed to track custom metric:', error);
|
||
|
|
}
|
||
|
|
};
|