basic interactable callbacks from webapp to kafka + redpanda console

This commit is contained in:
2025-10-23 15:48:29 +02:00
parent ae202631e1
commit 8797fb3976
8 changed files with 188 additions and 5 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
**/.env **/.env
**/.venv

View File

@@ -17,20 +17,34 @@ services:
kafka: kafka:
container_name: "PHANTOM-kafka" container_name: "PHANTOM-kafka"
image: confluentinc/cp-kafka:latest image: confluentinc/cp-kafka:7.5.0
depends_on: depends_on:
- zookeeper - zookeeper
environment: environment:
KAFKA_BROKER_ID: 1 KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 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 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
ALLOW_PLAINTEXT_LISTENER: yes KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
ports: ports:
- "${KAFKA_PORT:-9092}:9092" - "${KAFKA_PORT:-9092}:9092"
volumes: volumes:
- phantom_kafka_data:/var/lib/kafka/data - 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: volumes:
phantom_kafka_data: phantom_kafka_data:
phantom_redis_data: phantom_redis_data:

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
kafka-python
dotenv

View File

@@ -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 }
);
}
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { TrackingProvider } from "@/components/TrackingProvider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -27,7 +28,7 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
{children} <TrackingProvider>{children}</TrackingProvider>
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,8 @@
'use client';
import { useInteractionTracking } from '@/hooks/useInteractionTracking';
export const TrackingProvider = ({ children }: { children: React.ReactNode }) => {
useInteractionTracking();
return <>{children}</>;
};

View File

@@ -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<string, any>;
}) => {
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<string>('');
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);
};
}, []);
};

41
web/src/lib/kafka.ts Normal file
View File

@@ -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<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();
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;
}
};