@flavoai/fastfold
Version:
Flavo frontend package
503 lines • 17.2 kB
JavaScript
// ============================================================================
// FASTFOLD OBSERVABILITY - Analytics & Error Tracking
// ============================================================================
// ============================================================================
// REFERRER PARSING
// ============================================================================
const REFERRER_PATTERNS = [
[/facebook\.com|fb\.com|fbcdn/, 'facebook'],
[/twitter\.com|t\.co|x\.com/, 'twitter'],
[/instagram\.com/, 'instagram'],
[/google\.|googleapis/, 'google'],
[/bing\.com/, 'bing'],
[/linkedin\.com/, 'linkedin'],
[/youtube\.com/, 'youtube'],
[/reddit\.com/, 'reddit'],
[/tiktok\.com/, 'tiktok'],
[/pinterest\.com/, 'pinterest'],
];
function parseReferrer(referrer) {
if (!referrer)
return 'direct';
for (const [pattern, source] of REFERRER_PATTERNS) {
if (pattern.test(referrer))
return source;
}
return 'other';
}
// ============================================================================
// UUID GENERATION (browser-compatible)
// ============================================================================
function generateUUID() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for older browsers
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);
});
}
// ============================================================================
// OBSERVABILITY MANAGER
// ============================================================================
export class ObservabilityManager {
config;
sessionId;
visitorId;
userId = null;
userEmail = null;
pageviewCount = 0;
eventBuffer = [];
errorCount = 0;
eventCount = 0;
flushTimer = null;
isInitialized = false;
sessionStartTime = 0;
sessionEnded = false;
// Defaults
static DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
static DEFAULT_BATCH_INTERVAL_MS = 5000;
static DEFAULT_MAX_BATCH_SIZE = 10;
static DEFAULT_MAX_EVENTS_PER_SESSION = 1000;
static DEFAULT_MAX_ERRORS_PER_SESSION = 100;
static DEFAULT_ENDPOINT = '/api/observe';
static DEFAULT_APP_ID = 'local-app';
constructor(config) {
this.config = {
appId: ObservabilityManager.DEFAULT_APP_ID,
endpoint: ObservabilityManager.DEFAULT_ENDPOINT,
trackPageviews: true,
trackErrors: true,
trackCustomEvents: true,
sessionTimeoutMinutes: ObservabilityManager.DEFAULT_SESSION_TIMEOUT_MINUTES,
batchIntervalMs: ObservabilityManager.DEFAULT_BATCH_INTERVAL_MS,
maxBatchSize: ObservabilityManager.DEFAULT_MAX_BATCH_SIZE,
maxEventsPerSession: ObservabilityManager.DEFAULT_MAX_EVENTS_PER_SESSION,
maxErrorsPerSession: ObservabilityManager.DEFAULT_MAX_ERRORS_PER_SESSION,
...config,
};
// Initialize identities
this.visitorId = this.getOrCreateVisitorId();
this.sessionId = this.getOrCreateSessionId();
this.sessionStartTime = Date.now();
// Setup only in browser environment
if (typeof window !== 'undefined' && this.config.enabled) {
this.initialize();
}
}
/**
* Initialize observability - setup error handlers, track session start
*/
initialize() {
if (this.isInitialized)
return;
this.isInitialized = true;
// Setup error handlers
if (this.config.trackErrors) {
this.setupErrorHandlers();
}
// Track session start
this.send({
event_type: 'session_start',
referrer: document.referrer,
});
// Track initial pageview
if (this.config.trackPageviews) {
this.trackPageview();
}
// Setup visibility tracking for session end
this.setupVisibilityTracking();
// Setup SPA navigation tracking
this.setupNavigationTracking();
// Setup periodic flush
this.startFlushTimer();
// Flush on page unload
this.setupUnloadHandler();
}
/**
* Get or create persistent visitor ID from localStorage
*/
getOrCreateVisitorId() {
if (typeof localStorage === 'undefined') {
return generateUUID();
}
const key = 'sb_visitor_id';
let visitorId = localStorage.getItem(key);
if (!visitorId) {
visitorId = generateUUID();
localStorage.setItem(key, visitorId);
}
return visitorId;
}
/**
* Get or create session ID with hybrid timeout logic
*/
getOrCreateSessionId() {
if (typeof sessionStorage === 'undefined') {
return generateUUID();
}
const sessionKey = 'sb_session_id';
const activityKey = 'sb_last_activity';
const countKey = 'sb_pageview_count';
const lastActivity = sessionStorage.getItem(activityKey);
const existingSessionId = sessionStorage.getItem(sessionKey);
const existingCount = sessionStorage.getItem(countKey);
const now = Date.now();
const timeoutMs = (this.config.sessionTimeoutMinutes || 30) * 60 * 1000;
// Check if session has timed out
if (lastActivity) {
const elapsed = now - parseInt(lastActivity, 10);
if (elapsed > timeoutMs) {
// Session timed out - start new session
const newSessionId = generateUUID();
sessionStorage.setItem(sessionKey, newSessionId);
sessionStorage.setItem(countKey, '0');
this.pageviewCount = 0;
sessionStorage.setItem(activityKey, now.toString());
return newSessionId;
}
}
// Update last activity
sessionStorage.setItem(activityKey, now.toString());
// Restore pageview count
if (existingCount) {
this.pageviewCount = parseInt(existingCount, 10);
}
// Use existing session or create new one
if (existingSessionId) {
return existingSessionId;
}
const newSessionId = generateUUID();
sessionStorage.setItem(sessionKey, newSessionId);
sessionStorage.setItem(countKey, '0');
return newSessionId;
}
/**
* Update last activity timestamp (called on user interactions)
*/
updateActivity() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('sb_last_activity', Date.now().toString());
}
}
/**
* Setup global error handlers
*/
setupErrorHandlers() {
// Handle synchronous errors
window.addEventListener('error', (event) => {
this.trackError({
name: 'Error',
message: event.message,
stack: event.error?.stack || `${event.filename}:${event.lineno}:${event.colno}`,
}, { source: 'frontend' });
});
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
this.trackError({
name: 'UnhandledRejection',
message: error?.message || String(error),
stack: error?.stack,
}, { source: 'frontend' });
});
}
/**
* Setup visibility change tracking for session end
*/
setupVisibilityTracking() {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// Only send session_end once per session
if (!this.sessionEnded) {
this.sessionEnded = true;
// User is leaving - send session end with duration
const duration = Math.round((Date.now() - this.sessionStartTime) / 1000);
this.send({
event_type: 'session_end',
pageview_count: this.pageviewCount,
properties: { duration_seconds: duration },
});
}
this.flush();
}
else {
// User is back - update activity
this.updateActivity();
}
});
}
/**
* Setup SPA navigation tracking via History API
*/
setupNavigationTracking() {
// Listen for popstate (back/forward navigation)
window.addEventListener('popstate', () => {
if (this.config.trackPageviews) {
this.trackPageview();
}
});
// Monkey-patch pushState for client-side navigation
const originalPushState = history.pushState.bind(history);
history.pushState = (...args) => {
originalPushState(...args);
if (this.config.trackPageviews) {
// Small delay to let the URL update
setTimeout(() => this.trackPageview(), 0);
}
};
// Monkey-patch replaceState
const originalReplaceState = history.replaceState.bind(history);
history.replaceState = (...args) => {
originalReplaceState(...args);
if (this.config.trackPageviews) {
setTimeout(() => this.trackPageview(), 0);
}
};
}
/**
* Setup unload handler to flush remaining events
*/
setupUnloadHandler() {
window.addEventListener('beforeunload', () => {
this.flush();
});
// Also handle pagehide for mobile browsers
window.addEventListener('pagehide', () => {
this.flush();
});
}
/**
* Start the periodic flush timer
*/
startFlushTimer() {
if (this.flushTimer) {
clearInterval(this.flushTimer);
}
this.flushTimer = setInterval(() => {
this.flush();
}, this.config.batchIntervalMs || ObservabilityManager.DEFAULT_BATCH_INTERVAL_MS);
}
/**
* Send an event to the buffer
*/
send(event) {
// Check event limits
if (this.eventCount >= (this.config.maxEventsPerSession || ObservabilityManager.DEFAULT_MAX_EVENTS_PER_SESSION)) {
return;
}
const url = typeof window !== 'undefined' ? window.location.href : '';
const pagePath = typeof window !== 'undefined' ? window.location.pathname : '/';
const fullEvent = {
event_id: generateUUID(),
app_id: this.config.appId,
session_id: this.sessionId,
visitor_id: this.visitorId,
user_id: this.userId || undefined,
user_email: this.userEmail || undefined,
event_type: 'custom',
timestamp: new Date().toISOString(),
url,
page_path: pagePath,
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
...event,
};
this.eventBuffer.push(fullEvent);
this.eventCount++;
this.updateActivity();
// Check if we should flush immediately
if (this.eventBuffer.length >= (this.config.maxBatchSize || ObservabilityManager.DEFAULT_MAX_BATCH_SIZE)) {
this.flush();
}
}
/**
* Flush buffered events to the collector
*/
flush() {
if (this.eventBuffer.length === 0)
return;
const events = [...this.eventBuffer];
this.eventBuffer = [];
// Use sendBeacon for non-blocking, reliable delivery
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(events)], { type: 'application/json' });
navigator.sendBeacon(this.config.endpoint, blob);
}
else {
// Fallback to fetch
fetch(this.config.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(events),
keepalive: true,
}).catch(() => {
// Silently fail - observability should not break the app
});
}
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Get the current session ID (for API request headers)
*/
get currentSessionId() {
return this.sessionId;
}
/**
* Get the current visitor ID (for API request headers)
*/
get currentVisitorId() {
return this.visitorId;
}
/**
* Set the authenticated user ID and optional email
*/
setUser(userId, email) {
this.userId = userId;
this.userEmail = email || null;
}
/**
* Track a pageview event
*/
trackPageview(url) {
if (!this.config.trackPageviews)
return;
this.pageviewCount++;
// Persist pageview count
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem('sb_pageview_count', this.pageviewCount.toString());
}
this.send({
event_type: 'pageview',
url: url || (typeof window !== 'undefined' ? window.location.href : ''),
// Referrer is tracked once on session_start, not on every pageview
pageview_count: this.pageviewCount,
});
}
/**
* Track an error event
*/
trackError(error, context) {
if (!this.config.trackErrors)
return;
// Check error limit
if (this.errorCount >= (this.config.maxErrorsPerSession || ObservabilityManager.DEFAULT_MAX_ERRORS_PER_SESSION)) {
return;
}
this.errorCount++;
this.send({
event_type: context.source === 'backend' ? 'error_backend' : 'error_frontend',
error: {
message: error.message,
stack: error.stack,
component: context.component,
endpoint: context.endpoint,
status_code: context.status_code,
severity: 'error',
},
});
}
/**
* Track a custom event
*/
track(eventName, properties) {
if (!this.config.trackCustomEvents)
return;
this.send({
event_type: 'custom',
event_name: eventName,
properties,
});
}
/**
* Manually flush events (useful before navigation)
*/
forceFlush() {
this.flush();
}
/**
* Destroy the manager and cleanup
*/
destroy() {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = null;
}
this.flush();
this.isInitialized = false;
}
}
// ============================================================================
// SINGLETON INSTANCE & PUBLIC API
// ============================================================================
let observabilityInstance = null;
/**
* Initialize observability (called by FastfoldProvider)
*/
export function initializeObservability(config) {
if (observabilityInstance) {
observabilityInstance.destroy();
}
observabilityInstance = new ObservabilityManager(config);
return observabilityInstance;
}
/**
* Get the current observability instance
*/
export function getObservabilityInstance() {
return observabilityInstance;
}
/**
* Public observability API for use in apps
*/
export const observability = {
/**
* Track a custom event
* @example observability.track('signup', { plan: 'pro' })
*/
track: (eventName, properties) => {
observabilityInstance?.track(eventName, properties);
},
/**
* Manually track a pageview (for edge cases)
* @example observability.trackPageview('/custom-page')
*/
trackPageview: (url) => {
observabilityInstance?.trackPageview(url);
},
/**
* Set the authenticated user ID and optional email
* @example observability.setUser(user.id, user.email)
*/
setUser: (userId, email) => {
observabilityInstance?.setUser(userId, email);
},
/**
* Track an error (usually called automatically)
*/
trackError: (error, context) => {
observabilityInstance?.trackError(error, context);
},
/**
* Force flush any buffered events
*/
flush: () => {
observabilityInstance?.forceFlush();
},
/**
* Get the current session ID (for API headers)
*/
get sessionId() {
return observabilityInstance?.currentSessionId;
},
/**
* Get the current visitor ID (for API headers)
*/
get visitorId() {
return observabilityInstance?.currentVisitorId;
},
};
//# sourceMappingURL=observability.js.map