Files
manoon-headless/src/lib/analytics/providers/RybbitProvider.ts
Unchained 0b9ddeedc8 fix(analytics): properly forward client IPs to Rybbit and OpenPanel
- Create new API route /api/rybbit/track to proxy Rybbit tracking requests
- Extract real client IP from Cloudflare headers (cf-connecting-ip)
- Forward X-Forwarded-For and X-Real-IP headers to analytics backends
- Update OpenPanel proxy to also forward client IP
- Update next.config.ts rewrite to use internal API route

This fixes geo-location issues where all traffic appeared to come from
Cloudflare edge locations instead of actual visitor countries.
2026-04-01 10:24:09 +02:00

234 lines
6.4 KiB
TypeScript

"use client";
import type { AnalyticsEvent, AnalyticsProvider, UserData } from "../core/types";
declare global {
interface Window {
rybbit?: {
event: (eventName: string, eventData?: Record<string, any>) => void;
pageview: () => void;
};
}
}
type QueuedEvent = {
eventName: string;
properties?: Record<string, unknown>;
};
export class RybbitProvider implements AnalyticsProvider {
name = "Rybbit";
private isClient: boolean;
private eventQueue: QueuedEvent[] = [];
private flushInterval: ReturnType<typeof setInterval> | null = null;
private initialized = false;
constructor() {
this.isClient = typeof window !== "undefined";
if (this.isClient) {
console.log("[RybbitProvider] Constructor called");
// Start checking for rybbit availability
this.startFlushInterval();
// Also try to flush immediately in case script is already loaded
setTimeout(() => this.tryFlushQueue(), 100);
}
}
private startFlushInterval() {
// Check every 500ms for up to 15 seconds
let attempts = 0;
const maxAttempts = 30;
this.flushInterval = setInterval(() => {
attempts++;
const available = this.isAvailable();
if (available && !this.initialized) {
console.log("[RybbitProvider] Script became available, flushing queue");
this.initialized = true;
}
this.tryFlushQueue();
if (available || attempts >= maxAttempts) {
this.stopFlushInterval();
if (attempts >= maxAttempts && !available) {
console.warn("[RybbitProvider] Max attempts reached, script not loaded. Queue size:", this.eventQueue.length);
}
}
}, 500);
}
private stopFlushInterval() {
if (this.flushInterval) {
clearInterval(this.flushInterval);
this.flushInterval = null;
}
}
private tryFlushQueue() {
if (!this.isAvailable() || this.eventQueue.length === 0) {
return;
}
console.log(`[RybbitProvider] Flushing ${this.eventQueue.length} queued events`);
// Flush all queued events
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift();
if (event) {
this.sendEvent(event.eventName, event.properties);
}
}
}
isAvailable(): boolean {
return this.isClient && typeof window.rybbit?.event === "function";
}
private sendEvent(eventName: string, properties?: Record<string, unknown>): void {
try {
window.rybbit!.event(eventName, properties);
console.log(`[Rybbit] Event sent: ${eventName}`);
} catch (e) {
console.warn(`[Rybbit] Tracking error for ${eventName}:`, e);
}
}
private trackEvent(eventName: string, properties?: Record<string, unknown>): void {
if (!this.isClient) return;
if (this.isAvailable()) {
this.sendEvent(eventName, properties);
} else {
// Queue the event for later
this.eventQueue.push({ eventName, properties });
console.log(`[Rybbit] Queued event: ${eventName}, queue size: ${this.eventQueue.length}`);
}
}
track(event: AnalyticsEvent): void {
switch (event.type) {
case "product_viewed":
this.trackEvent("product_view", {
product_id: event.product.id,
product_name: event.product.name,
price: event.product.price,
currency: event.product.currency,
category: event.product.category,
variant: event.product.variant,
});
break;
case "add_to_cart":
this.trackEvent("add_to_cart", {
product_id: event.product.id,
product_name: event.product.name,
price: event.product.price,
currency: event.product.currency,
quantity: event.product.quantity,
variant: event.product.variant,
});
break;
case "remove_from_cart":
this.trackEvent("remove_from_cart", {
product_id: event.product.id,
product_name: event.product.name,
quantity: event.product.quantity,
});
break;
case "cart_view":
this.trackEvent("cart_view", {
cart_total: event.cart.total,
currency: event.cart.currency,
item_count: event.cart.item_count,
});
break;
case "checkout_started":
this.trackEvent("checkout_started", {
cart_total: event.cart.total,
currency: event.cart.currency,
item_count: event.cart.item_count,
items: event.cart.items,
});
break;
case "checkout_step":
this.trackEvent("checkout_step", {
step: event.step,
...event.data,
});
break;
case "order_completed":
this.trackEvent("order_completed", {
order_id: event.order.order_id,
order_number: event.order.order_number,
total: event.order.total,
currency: event.order.currency,
item_count: event.order.item_count,
shipping_cost: event.order.shipping_cost,
coupon_code: event.order.coupon_code,
customer_email: event.order.customer_email,
payment_method: event.order.payment_method,
revenue: event.order.total,
});
break;
case "search":
this.trackEvent("search", {
query: event.query,
results_count: event.results_count,
});
break;
case "external_link_click":
this.trackEvent("external_link_click", {
url: event.url,
label: event.label,
});
break;
case "wishlist_add":
this.trackEvent("wishlist_add", {
product_id: event.product.id,
product_name: event.product.name,
});
break;
case "user_login":
this.trackEvent("user_login", {
method: event.method,
});
break;
case "user_register":
this.trackEvent("user_register", {
method: event.method,
});
break;
case "newsletter_signup":
this.trackEvent("newsletter_signup", {
email: event.email,
source: event.source,
});
break;
}
}
identify(_user: UserData): void {
// Rybbit doesn't have explicit identify - it's handled automatically via cookies
}
revenue?(_amount: number, _currency: string, _properties?: Record<string, unknown>): Promise<void> {
// Revenue is tracked via order_completed event
return Promise.resolve();
}
}