diff --git a/web/src/app/api/session/route.ts b/web/src/app/api/session/route.ts new file mode 100644 index 0000000..455f589 --- /dev/null +++ b/web/src/app/api/session/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { randomUUID } from 'crypto'; + +const COOKIE_NAME = 'phantom_session_id'; +const isProd = process.env.NODE_ENV === 'production'; + +export async function GET(req: NextRequest) { + try { + // check for existing session cookie + const existingSession = req.cookies.get(COOKIE_NAME)?.value; + + if (existingSession) { + return NextResponse.json({ sessionId: existingSession }); + } + + // mint new session id + const sessionId = randomUUID(); + + const res = NextResponse.json({ sessionId }); + + // set httpOnly cookie with security flags + res.cookies.set({ + name: COOKIE_NAME, + value: sessionId, + httpOnly: true, + sameSite: 'lax', + secure: isProd, + path: '/', + maxAge: 60 * 60 * 24 * 30, // 30 days + }); + + return res; + } catch (err: any) { + console.error('session error:', err); + return NextResponse.json( + { error: err.message || 'unknown error' }, + { status: 500 } + ); + } +} diff --git a/web/src/hooks/useInteractionTracking.ts b/web/src/hooks/useInteractionTracking.ts index 317a2c3..6a0ac5a 100644 --- a/web/src/hooks/useInteractionTracking.ts +++ b/web/src/hooks/useInteractionTracking.ts @@ -1,17 +1,15 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import '@/lib/experiments' // ensure experiments lib is loaded -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); - // TODO: when creating new id send to exepriemtn tracking db - // match between sesion-id and experiment-id for this session - // so that we can identify all interactions aligning with a specific experiment goal. +const fetchSessionId = async (): Promise => { + 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 ''; } - return sid; }; const track = async (ev: { @@ -34,11 +32,17 @@ const track = async (ev: { export const useInteractionTracking = () => { const sidRef = useRef(''); + const [ready, setReady] = useState(false); useEffect(() => { - sidRef.current = genSessionId(); + // fetch session id from httpOnly cookie via API + fetchSessionId().then((sid) => { + sidRef.current = sid; + setReady(true); + }); const handleClick = (e: MouseEvent) => { + if (!sidRef.current) return; const tgt = e.target as HTMLElement; track({ sessionId: sidRef.current, @@ -54,6 +58,7 @@ export const useInteractionTracking = () => { }; const handleScroll = () => { + if (!sidRef.current) return; track({ sessionId: sidRef.current, eventType: 'scroll', @@ -65,6 +70,7 @@ export const useInteractionTracking = () => { }; const handlePageView = () => { + if (!sidRef.current) return; track({ sessionId: sidRef.current, eventType: 'pageview', @@ -85,6 +91,7 @@ export const useInteractionTracking = () => { interactionType: DefinedInteractions, metadata?: Record ) => { + if (!sidRef.current) return; track({ sessionId: sidRef.current, eventType: interactionType, @@ -95,23 +102,24 @@ export const useInteractionTracking = () => { }); }; + 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', (e: Event) => { - const customEvent = e as CustomEvent; - handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata); - }); + document.addEventListener('definedInteraction', definedInteractionListener); // TOO NOISY: enable if needed but tbh not worth it //window.addEventListener('scroll', handleScroll, { passive: true }); return () => { document.removeEventListener('click', handleClick); - document.removeEventListener('definedInteraction', (e: Event) => { - const customEvent = e as CustomEvent; - handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata); - }); + document.removeEventListener('definedInteraction', definedInteractionListener); //window.removeEventListener('scroll', handleScroll); }; - }, []); + }, [ready]); };