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.
This commit is contained in:
Unchained
2026-04-01 07:42:34 +02:00
parent a3873bb50d
commit 0b9ddeedc8
10 changed files with 169 additions and 338 deletions

View File

@@ -1,6 +1,5 @@
"use client";
import { useOpenPanel } from "@openpanel/nextjs";
import { useCallback } from "react";
import {
trackRybbitProductView,
@@ -19,106 +18,6 @@ import {
} from "@/lib/services/RybbitService";
export function useAnalytics() {
const op = useOpenPanel();
// Helper to track with both OpenPanel and Rybbit
const trackDual = useCallback((
eventName: string,
openPanelData: Record<string, any>
) => {
// OpenPanel tracking
try {
op.track(eventName, openPanelData);
} catch (e) {
console.error("[OpenPanel] Tracking error:", e);
}
// Rybbit tracking (fire-and-forget)
try {
switch (eventName) {
case "product_viewed":
trackRybbitProductView({
id: openPanelData.product_id,
name: openPanelData.product_name,
price: openPanelData.price,
currency: openPanelData.currency,
category: openPanelData.category,
});
break;
case "add_to_cart":
trackRybbitAddToCart({
id: openPanelData.product_id,
name: openPanelData.product_name,
price: openPanelData.price,
currency: openPanelData.currency,
quantity: openPanelData.quantity,
variant: openPanelData.variant,
});
break;
case "remove_from_cart":
trackRybbitRemoveFromCart({
id: openPanelData.product_id,
name: openPanelData.product_name,
quantity: openPanelData.quantity,
});
break;
case "cart_view":
trackRybbitCartView({
total: openPanelData.cart_total,
currency: openPanelData.currency,
item_count: openPanelData.item_count,
});
break;
case "checkout_started":
trackRybbitCheckoutStarted({
total: openPanelData.cart_total,
currency: openPanelData.currency,
item_count: openPanelData.item_count,
items: openPanelData.items,
});
break;
case "checkout_step":
trackRybbitCheckoutStep(openPanelData.step, openPanelData);
break;
case "order_completed":
trackRybbitOrderCompleted({
order_id: openPanelData.order_id,
order_number: openPanelData.order_number,
total: openPanelData.total,
currency: openPanelData.currency,
item_count: openPanelData.item_count,
shipping_cost: openPanelData.shipping_cost,
customer_email: openPanelData.customer_email,
payment_method: openPanelData.payment_method,
});
break;
case "search":
trackRybbitSearch(openPanelData.query, openPanelData.results_count);
break;
case "external_link_click":
trackRybbitExternalLink(openPanelData.url, openPanelData.label);
break;
case "wishlist_add":
trackRybbitWishlistAdd({
id: openPanelData.product_id,
name: openPanelData.product_name,
});
break;
case "user_login":
trackRybbitUserLogin(openPanelData.method);
break;
case "user_register":
trackRybbitUserRegister(openPanelData.method);
break;
case "newsletter_signup":
trackRybbitNewsletterSignup(openPanelData.email, openPanelData.source);
break;
}
} catch (e) {
console.warn("[Rybbit] Tracking error:", e);
}
}, [op]);
const trackProductView = useCallback((product: {
id: string;
name: string;
@@ -126,15 +25,14 @@ export function useAnalytics() {
currency: string;
category?: string;
}) => {
trackDual("product_viewed", {
product_id: product.id,
product_name: product.name,
trackRybbitProductView({
id: product.id,
name: product.name,
price: product.price,
currency: product.currency,
category: product.category,
source: "client",
});
}, [trackDual]);
}, []);
const trackAddToCart = useCallback((product: {
id: string;
@@ -144,42 +42,39 @@ export function useAnalytics() {
quantity: number;
variant?: string;
}) => {
trackDual("add_to_cart", {
product_id: product.id,
product_name: product.name,
trackRybbitAddToCart({
id: product.id,
name: product.name,
price: product.price,
currency: product.currency,
quantity: product.quantity,
variant: product.variant,
source: "client",
});
}, [trackDual]);
}, []);
const trackRemoveFromCart = useCallback((product: {
id: string;
name: string;
quantity: number;
}) => {
trackDual("remove_from_cart", {
product_id: product.id,
product_name: product.name,
trackRybbitRemoveFromCart({
id: product.id,
name: product.name,
quantity: product.quantity,
source: "client",
});
}, [trackDual]);
}, []);
const trackCartView = useCallback((cart: {
total: number;
currency: string;
item_count: number;
}) => {
trackDual("cart_view", {
cart_total: cart.total,
trackRybbitCartView({
total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
source: "client",
});
}, [trackDual]);
}, []);
const trackCheckoutStarted = useCallback((cart: {
total: number;
@@ -192,22 +87,17 @@ export function useAnalytics() {
price: number;
}>;
}) => {
trackDual("checkout_started", {
cart_total: cart.total,
trackRybbitCheckoutStarted({
total: cart.total,
currency: cart.currency,
item_count: cart.item_count,
items: cart.items,
source: "client",
});
}, [trackDual]);
}, []);
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
trackDual("checkout_step", {
step,
...data,
source: "client",
});
}, [trackDual]);
trackRybbitCheckoutStep(step, data);
}, []);
const trackOrderCompleted = useCallback(async (order: {
order_id: string;
@@ -221,8 +111,8 @@ export function useAnalytics() {
}) => {
console.log("[Analytics] Tracking order:", order.order_number);
// Track with both OpenPanel and Rybbit
trackDual("order_completed", {
// Rybbit tracking
trackRybbitOrderCompleted({
order_id: order.order_id,
order_number: order.order_number,
total: order.total,
@@ -231,20 +121,8 @@ export function useAnalytics() {
shipping_cost: order.shipping_cost,
customer_email: order.customer_email,
payment_method: order.payment_method,
source: "client",
});
// OpenPanel revenue tracking
try {
op.revenue(order.total, {
currency: order.currency,
transaction_id: order.order_number,
source: "client",
});
} catch (e) {
console.error("[OpenPanel] Revenue tracking error:", e);
}
// Server-side tracking for reliability
try {
const response = await fetch("/api/analytics/track-order", {
@@ -268,73 +146,48 @@ export function useAnalytics() {
} catch (e) {
console.error("[Server Analytics] API call failed:", e);
}
}, [op, trackDual]);
}, []);
const trackSearch = useCallback((query: string, results_count: number) => {
trackDual("search", {
query,
results_count,
source: "client",
});
}, [trackDual]);
trackRybbitSearch(query, results_count);
}, []);
const trackExternalLink = useCallback((url: string, label?: string) => {
trackDual("external_link_click", {
url,
label,
source: "client",
});
}, [trackDual]);
trackRybbitExternalLink(url, label);
}, []);
const trackWishlistAdd = useCallback((product: {
id: string;
name: string;
}) => {
trackDual("wishlist_add", {
product_id: product.id,
product_name: product.name,
source: "client",
trackRybbitWishlistAdd({
id: product.id,
name: product.name,
});
}, [trackDual]);
}, []);
const trackUserLogin = useCallback((method: string) => {
trackDual("user_login", {
method,
source: "client",
});
}, [trackDual]);
trackRybbitUserLogin(method);
}, []);
const trackUserRegister = useCallback((method: string) => {
trackDual("user_register", {
method,
source: "client",
});
}, [trackDual]);
trackRybbitUserRegister(method);
}, []);
const trackNewsletterSignup = useCallback((email: string, source: string) => {
trackDual("newsletter_signup", {
email,
source,
});
}, [trackDual]);
trackRybbitNewsletterSignup(email, source);
}, []);
const identifyUser = useCallback((user: {
// No-op placeholder for identifyUser (OpenPanel removed)
const identifyUser = useCallback((_user: {
profileId: string;
email?: string;
firstName?: string;
lastName?: string;
}) => {
try {
op.identify({
profileId: user.profileId,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
});
} catch (e) {
console.error("[OpenPanel] Identify error:", e);
}
}, [op]);
// OpenPanel was removed - this is now a no-op
// User identification is handled by Rybbit automatically via cookies
}, []);
return {
trackProductView,