From 8797fb3976558c377b9c166d8f5a86bb6186b8bb Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 23 Oct 2025 15:48:29 +0200 Subject: [PATCH] basic interactable callbacks from webapp to kafka + redpanda console --- .gitignore | 3 +- docker-compose.yml | 20 +++++- requirements.txt | 2 + web/src/app/api/track/route.ts | 33 ++++++++++ web/src/app/layout.tsx | 3 +- web/src/components/TrackingProvider.tsx | 8 +++ web/src/hooks/useInteractionTracking.ts | 83 +++++++++++++++++++++++++ web/src/lib/kafka.ts | 41 ++++++++++++ 8 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 requirements.txt create mode 100644 web/src/app/api/track/route.ts create mode 100644 web/src/components/TrackingProvider.tsx create mode 100644 web/src/hooks/useInteractionTracking.ts create mode 100644 web/src/lib/kafka.ts diff --git a/.gitignore b/.gitignore index 845959d..f4056c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -**/.env \ No newline at end of file +**/.env +**/.venv \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5d04139..abfb77d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,20 +17,34 @@ services: kafka: container_name: "PHANTOM-kafka" - image: confluentinc/cp-kafka:latest + image: confluentinc/cp-kafka:7.5.0 depends_on: - zookeeper environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092 + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - ALLOW_PLAINTEXT_LISTENER: yes + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ports: - "${KAFKA_PORT:-9092}:9092" volumes: - phantom_kafka_data:/var/lib/kafka/data + redpanda-console: + container_name: "PHANTOM-redpanda-console" + image: docker.redpanda.com/redpandadata/console:latest + depends_on: + - kafka + environment: + KAFKA_BROKERS: kafka:29092 + ports: + - "${REDPANDA_CONSOLE_PORT:-8080}:8080" + restart: unless-stopped + volumes: phantom_kafka_data: phantom_redis_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..627da29 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +kafka-python +dotenv diff --git a/web/src/app/api/track/route.ts b/web/src/app/api/track/route.ts new file mode 100644 index 0000000..eaa4f57 --- /dev/null +++ b/web/src/app/api/track/route.ts @@ -0,0 +1,33 @@ +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 } + ); + } +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index f7fa87e..2cef36e 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { TrackingProvider } from "@/components/TrackingProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,7 +28,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/web/src/components/TrackingProvider.tsx b/web/src/components/TrackingProvider.tsx new file mode 100644 index 0000000..40b5bd2 --- /dev/null +++ b/web/src/components/TrackingProvider.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { useInteractionTracking } from '@/hooks/useInteractionTracking'; + +export const TrackingProvider = ({ children }: { children: React.ReactNode }) => { + useInteractionTracking(); + return <>{children}; +}; diff --git a/web/src/hooks/useInteractionTracking.ts b/web/src/hooks/useInteractionTracking.ts new file mode 100644 index 0000000..8a7e44d --- /dev/null +++ b/web/src/hooks/useInteractionTracking.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react'; + +const genSessionId = () => { + if (typeof window === 'undefined') return ''; + let sid = sessionStorage.getItem('phantom_session_id'); + if (!sid) { + sid = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + sessionStorage.setItem('phantom_session_id', sid); + } + return sid; +}; + +const track = async (ev: { + sessionId: string; + eventType: string; + targetEl?: string; + targetUrl?: string; + metadata?: Record; +}) => { + try { + await fetch('/api/track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ev), + }); + } catch (err) { + console.error('track failed:', err); + } +}; + +export const useInteractionTracking = () => { + const sidRef = useRef(''); + + useEffect(() => { + sidRef.current = genSessionId(); + + const handleClick = (e: MouseEvent) => { + const tgt = e.target as HTMLElement; + track({ + sessionId: sidRef.current, + eventType: 'click', + targetEl: tgt.tagName, + targetUrl: tgt instanceof HTMLAnchorElement ? tgt.href : undefined, + metadata: { + x: e.clientX, + y: e.clientY, + path: window.location.pathname, + }, + }); + }; + + const handleScroll = () => { + track({ + sessionId: sidRef.current, + eventType: 'scroll', + metadata: { + scrollY: window.scrollY, + path: window.location.pathname, + }, + }); + }; + + const handlePageView = () => { + track({ + sessionId: sidRef.current, + eventType: 'pageview', + metadata: { + path: window.location.pathname, + referrer: document.referrer, + }, + }); + }; + + handlePageView(); + document.addEventListener('click', handleClick); + window.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + document.removeEventListener('click', handleClick); + window.removeEventListener('scroll', handleScroll); + }; + }, []); +}; diff --git a/web/src/lib/kafka.ts b/web/src/lib/kafka.ts new file mode 100644 index 0000000..2027454 --- /dev/null +++ b/web/src/lib/kafka.ts @@ -0,0 +1,41 @@ +import { Kafka, Producer } from 'kafkajs'; + +let producer: Producer | null = null; + +const kafka = new Kafka({ + clientId: 'phantom-web', + brokers: [`${process.env.KAFKA_HOST || 'localhost'}:${process.env.KAFKA_PORT || '9092'}`], +}); + +export const getProducer = async (): Promise => { + 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; + ts: number; +}) => { + 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; + } +};