better session cookie management

This commit is contained in:
2025-11-06 16:35:58 +01:00
parent 8560d09097
commit 425dd2d9ef
2 changed files with 69 additions and 21 deletions

View File

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

View File

@@ -1,17 +1,15 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import '@/lib/experiments' // ensure experiments lib is loaded import '@/lib/experiments' // ensure experiments lib is loaded
const genSessionId = () => { const fetchSessionId = async (): Promise<string> => {
if (typeof window === 'undefined') return ''; try {
let sid = sessionStorage.getItem('phantom_session_id'); const res = await fetch('/api/session');
if (!sid) { const data = await res.json();
sid = `${Date.now()}-${Math.random().toString(36).slice(2)}`; return data.sessionId || '';
sessionStorage.setItem('phantom_session_id', sid); } catch (err) {
// TODO: when creating new id send to exepriemtn tracking db console.error('failed to fetch session:', err);
// match between sesion-id and experiment-id for this session return '';
// so that we can identify all interactions aligning with a specific experiment goal.
} }
return sid;
}; };
const track = async (ev: { const track = async (ev: {
@@ -34,11 +32,17 @@ const track = async (ev: {
export const useInteractionTracking = () => { export const useInteractionTracking = () => {
const sidRef = useRef<string>(''); const sidRef = useRef<string>('');
const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
sidRef.current = genSessionId(); // fetch session id from httpOnly cookie via API
fetchSessionId().then((sid) => {
sidRef.current = sid;
setReady(true);
});
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (!sidRef.current) return;
const tgt = e.target as HTMLElement; const tgt = e.target as HTMLElement;
track({ track({
sessionId: sidRef.current, sessionId: sidRef.current,
@@ -54,6 +58,7 @@ export const useInteractionTracking = () => {
}; };
const handleScroll = () => { const handleScroll = () => {
if (!sidRef.current) return;
track({ track({
sessionId: sidRef.current, sessionId: sidRef.current,
eventType: 'scroll', eventType: 'scroll',
@@ -65,6 +70,7 @@ export const useInteractionTracking = () => {
}; };
const handlePageView = () => { const handlePageView = () => {
if (!sidRef.current) return;
track({ track({
sessionId: sidRef.current, sessionId: sidRef.current,
eventType: 'pageview', eventType: 'pageview',
@@ -85,6 +91,7 @@ export const useInteractionTracking = () => {
interactionType: DefinedInteractions, interactionType: DefinedInteractions,
metadata?: Record<string, any> metadata?: Record<string, any>
) => { ) => {
if (!sidRef.current) return;
track({ track({
sessionId: sidRef.current, sessionId: sidRef.current,
eventType: interactionType, 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(); handlePageView();
document.addEventListener('click', handleClick); document.addEventListener('click', handleClick);
document.addEventListener('definedInteraction', (e: Event) => { document.addEventListener('definedInteraction', definedInteractionListener);
const customEvent = e as CustomEvent;
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
});
// TOO NOISY: enable if needed but tbh not worth it // TOO NOISY: enable if needed but tbh not worth it
//window.addEventListener('scroll', handleScroll, { passive: true }); //window.addEventListener('scroll', handleScroll, { passive: true });
return () => { return () => {
document.removeEventListener('click', handleClick); document.removeEventListener('click', handleClick);
document.removeEventListener('definedInteraction', (e: Event) => { document.removeEventListener('definedInteraction', definedInteractionListener);
const customEvent = e as CustomEvent;
handleDefinedInteraction(customEvent.detail.interactionType, customEvent.detail.metadata);
});
//window.removeEventListener('scroll', handleScroll); //window.removeEventListener('scroll', handleScroll);
}; };
}, []); }, [ready]);
}; };