migrated api of ingestion

This commit is contained in:
2025-11-06 19:03:12 +01:00
parent 0cec8487ba
commit d3d5f39ec5
7 changed files with 817 additions and 391 deletions

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendEvent } from '@/lib/kafka';
import type { EventBase } from '@/lib/events';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const storeMode = process.env.STORE_MODE || 'hotel';
const userAgent = req.headers.get('user-agent') || undefined;
const event: EventBase = {
...body,
storeMode,
userAgent,
ts: body.ts || new Date().toISOString(),
};
await sendEvent(event);
if (process.env.NEXT_PUBLIC_APP_ENV === 'dev') {
console.log('[ingest]', event);
}
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('[ingest error]', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -1,33 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendInteractionEvent } from '@/lib/kafka';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { sessionId, eventType, targetEl, targetUrl, metadata } = body;
if (!sessionId || !eventType) {
return NextResponse.json(
{ error: 'sessionId and eventType required' },
{ status: 400 }
);
}
await sendInteractionEvent({
sessionId,
eventType,
targetEl,
targetUrl,
metadata,
ts: Date.now(),
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('track error:', err);
return NextResponse.json(
{ error: err.message || 'unknown error' },
{ status: 500 }
);
}
}

View File

@@ -4,3 +4,4 @@ export { default as Input } from './Input';
export { default as DateInput } from './DateInput';
export { default as RadioGroup } from './RadioGroup';
export { default as Dropdown, DropdownCounter } from './Dropdown';
export { default as Navigation } from './Navigation';

View File

@@ -1,33 +1,34 @@
import { useEffect, useRef, useState } from 'react';
import '@/lib/experiments' // ensure experiments lib is loaded
import type { EventName } from '@/lib/events';
const fetchSessionId = async (): Promise<string> => {
try {
const res = await fetch('/api/session');
const data = await res.json();
return data.sessionId || '';
} catch (err) {
console.error('failed to fetch session:', err);
return '';
}
try {
const res = await fetch('/api/session');
const data = await res.json();
return data.sessionId || '';
} catch (err) {
console.error('failed to fetch session:', err);
return '';
}
};
const track = async (ev: {
sessionId: string;
eventType: string;
targetEl?: string;
targetUrl?: string;
metadata?: Record<string, any>;
sessionId: string;
eventName: EventName;
page: string;
productId?: string;
metadata?: Record<string, unknown>;
}) => {
try {
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
});
} catch (err) {
console.error('track failed:', err);
}
try {
await fetch('/api/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ev),
});
} catch (err) {
console.error('track failed:', err);
}
};
export const useInteractionTracking = () => {
@@ -44,82 +45,61 @@ export const useInteractionTracking = () => {
const handleClick = (e: MouseEvent) => {
if (!sidRef.current) return;
const tgt = e.target as HTMLElement;
const page = window.location.pathname;
track({
sessionId: sidRef.current,
eventType: 'click',
targetEl: tgt.tagName,
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
eventName: 'click',
page,
metadata: {
x: e.clientX,
y: e.clientY,
path: window.location.pathname,
},
});
};
const handleScroll = () => {
if (!sidRef.current) return;
track({
sessionId: sidRef.current,
eventType: 'scroll',
metadata: {
scrollY: window.scrollY,
path: window.location.pathname,
targetEl: tgt.tagName,
targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined,
},
});
};
const handlePageView = () => {
if (!sidRef.current) return;
const page = window.location.pathname;
track({
sessionId: sidRef.current,
eventType: 'pageview',
eventName: 'page_view',
page,
metadata: {
path: window.location.pathname,
referrer: document.referrer,
},
});
};
enum DefinedInteractions {
ADD_TO_CART = 'add_to_cart',
PURCHASE = 'purchase',
}
// called when clicking on "Add to Cart" button or "Purchase" button
const handleDefinedInteraction = (
interactionType: DefinedInteractions,
metadata?: Record<string, any>
) => {
// called for canonical events dispatched via custom events
const handleDefinedInteraction = (e: Event) => {
if (!sidRef.current) return;
const customEvent = e as CustomEvent<{
eventName: EventName;
productId?: string;
metadata?: Record<string, unknown>;
}>;
const page = window.location.pathname;
track({
sessionId: sidRef.current,
eventType: interactionType,
metadata: {
path: window.location.pathname,
...metadata,
},
eventName: customEvent.detail.eventName,
page,
productId: customEvent.detail.productId,
metadata: customEvent.detail.metadata,
});
};
const definedInteractionListener = (e: Event) => {
const customEvent = e as CustomEvent;
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
};
// wait for session to be ready before tracking
if (!ready) return;
handlePageView();
document.addEventListener('click', handleClick);
document.addEventListener('definedInteraction', definedInteractionListener);
// TOO NOISY: enable if needed but tbh not worth it
//window.addEventListener('scroll', handleScroll, { passive: true });
document.addEventListener('definedInteraction', handleDefinedInteraction);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('definedInteraction', definedInteractionListener);
//window.removeEventListener('scroll', handleScroll);
document.removeEventListener('definedInteraction', handleDefinedInteraction);
};
}, [ready]);
};

View File

@@ -1,42 +1,35 @@
import { Kafka, Producer } from 'kafkajs';
import type { EventBase } from './events';
let producer: Producer | null = null;
const kafka = new Kafka({
clientId: 'phantom-web',
brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`],
clientId: 'phantom-web',
brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`],
});
export const getProducer = async (): Promise<Producer> => {
if (!producer) {
producer = kafka.producer();
await producer.connect();
}
return producer;
if (!producer) {
producer = kafka.producer();
await producer.connect();
}
return producer;
};
export const sendInteractionEvent = async (ev: {
sessionId: string;
eventType: string;
targetEl?: string;
targetUrl?: string;
metadata?: Record<string, any>;
ts: number;
}) => {
const p = await getProducer();
// add to the metadata
await p.send({
topic: 'user-interactions',
messages: [{
key: ev.sessionId,
value: JSON.stringify(ev),
}],
});
export const sendEvent = async (ev: EventBase) => {
const p = await getProducer();
await p.send({
topic: 'user-interactions',
messages: [{
key: ev.sessionId,
value: JSON.stringify(ev),
}],
});
};
export const disconnect = async () => {
if (producer) {
await producer.disconnect();
producer = null;
}
if (producer) {
await producer.disconnect();
producer = null;
}
};

View File

@@ -4,9 +4,10 @@ export function middleware(req: NextRequest) {
const mode = process.env.STORE_MODE;
const { pathname } = req.nextUrl;
// skip rewrites for api routes, static files, and next internals
// skip rewrites for api routes, admin routes, static files, and next internals
if (
pathname.startsWith('/api') ||
pathname.startsWith('/admin') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/static') ||
pathname.includes('.')