mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 08:33:36 +00:00
basic interactable callbacks from webapp to kafka + redpanda console
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
**/.env
|
**/.env
|
||||||
|
**/.venv
|
||||||
@@ -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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
kafka-python
|
||||||
|
dotenv
|
||||||
33
web/src/app/api/track/route.ts
Normal file
33
web/src/app/api/track/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
8
web/src/components/TrackingProvider.tsx
Normal file
8
web/src/components/TrackingProvider.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useInteractionTracking } from '@/hooks/useInteractionTracking';
|
||||||
|
|
||||||
|
export const TrackingProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
useInteractionTracking();
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
83
web/src/hooks/useInteractionTracking.ts
Normal file
83
web/src/hooks/useInteractionTracking.ts
Normal 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
41
web/src/lib/kafka.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user