Skip to content

Instantly share code, notes, and snippets.

@kyleledbetter
Created November 6, 2025 23:52
Show Gist options
  • Select an option

  • Save kyleledbetter/99f4eed3cad8ec08278ad127a0edbc25 to your computer and use it in GitHub Desktop.

Select an option

Save kyleledbetter/99f4eed3cad8ec08278ad127a0edbc25 to your computer and use it in GitHub Desktop.
Simple event and click tracking from React to a single Supabase table

Dreambase.ai - User Behavioral Event Data Stored in Supabase

Overview

We want to track user interactions with the product to capture behavioral data and enrich the database data already in Supabase for full product analytics. Instead of using a third party javascript library, we will build our own lean analytics system with a Next.js/React provider built on top of existing Supabase clients (@supabase/supabase-js and @supabase/ssr). We will use intelligent client-side aggregation and smart batching to capture only the most essential behavioral data, storing it in a single optimized Supabase table designed for both human analysis and LLM consumption via Supabase MCP.

Lean Analytics Approach

Core Principles:

  • Aggregate data client-side before sending (minimal payload)
  • Leverage existing Supabase session/user context (no duplicate auth)
  • Focus on business-critical events and goal conversions
  • Design data structure for LLM analysis and product data joins
  • Use sampling strategies to capture patterns without overhead

Events to Track (Intelligently Aggregated)

Goal Events (100% capture):

  • User conversions (signups, workspace creation, subscription events)
  • Feature adoption milestones (first chat, first report, etc.)
  • Error states and abandonment points

Behavioral Patterns (Aggregated):

  • Path sequences through the application
  • Time spent on key pages/features
  • Interaction clusters by element type/function
  • Form interaction patterns (not values)

Performance & Technical (Sampled):

  • Page load times for conversion-critical paths
  • JavaScript errors correlated to user journeys
  • API route performance for user-facing endpoints

LLM Interactions (Full capture for observability):

  • Model usage, token consumption, response times
  • Prompt effectiveness and user satisfaction patterns
  • Cost attribution per user/workspace/session

Database Design (LLM-Optimized)

Single Smart Table (Append-Only Batches):

CREATE TABLE analytics_user_sessions (
  -- Primary identifiers (for joins with product data)
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid REFERENCES auth.users(id),
  session_id text NOT NULL, -- Supabase session_id from JWT or anon-{uuid} for anonymous
  batch_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(), -- unique per flush
  batch_seq integer NOT NULL DEFAULT 0, -- optional ordering within a session
  
  -- Temporal data (LLM-friendly for time-based analysis)
  started_at timestamptz DEFAULT now(),
  ended_at timestamptz,
  session_duration_ms integer GENERATED ALWAYS AS (
    EXTRACT(EPOCH FROM (ended_at - started_at)) * 1000
  ) STORED,
  
  -- Navigation patterns (for funnel analysis)
  path_sequence text[] DEFAULT '{}'::text[], -- ["/dashboard", "/workspace/abc", "/chat"]
  path_timings integer[] DEFAULT '{}'::integer[], -- [0, 45000, 120000] ms from session start
  entry_point text, -- first page in session
  exit_point text, -- last page in session
  
  -- Behavioral aggregations (for pattern recognition)
  interaction_summary jsonb DEFAULT '{}'::jsonb, -- {"btn_primary": 5, "nav_workspace": 2}
  goal_events jsonb DEFAULT '{}'::jsonb, -- {"workspace_created": "2024-01-01T10:00:00Z"}
  feature_interactions jsonb DEFAULT '{}'::jsonb, -- {"chat": 3, "reports": 1, "settings": 0}
  
  -- Performance metrics (for UX analysis)
  page_load_times jsonb DEFAULT '{}'::jsonb, -- {"/dashboard": 1200, "/chat": 800}
  error_events jsonb DEFAULT '[]'::jsonb, -- [{"type": "js_error", "page": "/chat", "time": 45000}]
  
  -- LLM observability (for AI usage analysis)
  llm_interactions jsonb DEFAULT '[]'::jsonb, -- detailed AI usage patterns
  llm_token_usage integer DEFAULT 0, -- total tokens consumed
  llm_cost_estimate decimal(10,4) DEFAULT 0, -- estimated cost in USD
  
  -- Flexible metadata for app-specific context
  metadata jsonb DEFAULT '{}'::jsonb, -- workspace_id, project_id, team_id, org_id, supabase_session_id, etc.
  
  -- Computed fields for easy LLM querying
  session_type text, -- "conversion", "exploration", "support", etc.
  conversion_achieved boolean DEFAULT false,
  pages_visited integer GENERATED ALWAYS AS (array_length(path_sequence, 1)) STORED,
  bounce_session boolean GENERATED ALWAYS AS (array_length(path_sequence, 1) = 1) STORED,
  
  -- Metadata for data quality
  created_at timestamptz DEFAULT now(),
  updated_at timestamptz DEFAULT now(),

  -- Size safety
  CONSTRAINT check_path_sequence_size CHECK (array_length(path_sequence, 1) <= 100),
  CONSTRAINT check_path_timings_size CHECK (array_length(path_timings, 1) <= 100)
);

-- Enable RLS
ALTER TABLE analytics_user_sessions ENABLE ROW LEVEL SECURITY;

-- Read: allow the owner or your own super-admin function
create policy "Read analytics_user_sessions"
on public.analytics_user_sessions
for select
to authenticated
using (user_id = auth.uid() or is_super_admin());


-- Indexes optimized for LLM queries and joins
CREATE INDEX idx_analytics_user_time ON analytics_user_sessions (user_id, started_at);
CREATE INDEX idx_analytics_session_time ON analytics_user_sessions (session_id, started_at);
-- Ensure composite uniqueness for upserts
CREATE UNIQUE INDEX IF NOT EXISTS ux_analytics_session_batch
  ON analytics_user_sessions (session_id, batch_seq);

-- View for LLM consumption (flattened for easy analysis)
CREATE VIEW analytics_session_summary AS
SELECT
  s.session_id,
  MIN(s.started_at) AS started_at,
  MAX(s.ended_at) AS ended_at,
  COUNT(*) AS batch_count,
  BOOL_OR(s.conversion_achieved) AS conversion_achieved,
  SUM(s.llm_token_usage) AS llm_token_usage,
  SUM(s.llm_cost_estimate) AS llm_cost_estimate
FROM analytics_user_sessions s
GROUP BY s.session_id;

Optional indexes to add later as data grows (keep base table light initially):

  • JSONB GIN on metadata (prefer jsonb_path_ops)
  • JSONB GIN on goal_events, feature_interactions
  • GIN on path_sequence
  • B-Tree on (session_type, started_at)

Implementation Strategy

Client-Side Intelligence:

  • React provider for session-level aggregation
  • Smart batching (goal events immediate, patterns on interval/session-end)
  • Intelligent sampling based on user behavior patterns
  • PII-safe aggregation (semantic labels, not raw content)

Server Actions Integration:

  • Leverage existing Supabase service role for writes
  • Append-only inserts per batch (unique batch_id)
  • Batch processing for performance optimization

LLM Integration Features:

  • Structured JSONB fields for easy LLM parsing
  • Computed columns for common analysis patterns
  • Time-series friendly design for temporal analysis
  • Clear foreign key relationships for product data joins

Primary Reporting Goals (LLM-Queryable)

User Journey Analysis:

  1. Funnel Analytics: Track conversion paths from entry to goal completion
  2. Behavioral Segmentation: Identify user types (explorers, converters, bouncers) based on session patterns
  3. Feature Adoption: Measure first-use and engagement patterns for new features
  4. Abandonment Analysis: Identify drop-off points and friction areas in user flows

Product Intelligence: 5. Performance Impact: Correlate page load times with user engagement and conversions 6. Error Attribution: Connect technical issues to specific user journeys and business impact 7. Workspace Context: Analyze behavior patterns by workspace size, plan, and industry 8. Time-based Patterns: Understand usage patterns by hour, day, and user lifecycle stage

LLM Observability & Optimization: 9. AI Usage Analytics: Track model performance, token consumption, and cost attribution 10. Prompt Effectiveness: Measure user satisfaction and interaction success rates 11. Feature AI Integration: Understand how AI features impact overall product engagement 12. Cost Optimization: Identify opportunities to reduce LLM costs while maintaining UX

LLM Query Examples

The data structure enables powerful LLM analysis through Supabase MCP:

Business Intelligence Queries:

  • "Show me conversion funnels for users who interact with AI features vs those who don't"
  • "What are the most common paths users take before churning?"
  • "Compare feature adoption rates across different user plans (metadata->>'plan')"
  • "Analyze workspace-level engagement patterns (metadata->>'workspace_id')"

Product Optimization:

  • "Identify pages with high load times that correlate with user abandonment"
  • "Find user behavior patterns that predict successful onboarding"
  • "Show me error patterns by team size (metadata->>'team_id')"
  • "Compare session patterns between different project types (metadata->>'project_id')"

AI/LLM Analysis:

  • "Calculate ROI of AI features based on user engagement and conversion lift"
  • "Identify which AI interactions lead to the highest user satisfaction"
  • "Show cost per conversion for different AI-powered user journeys"
  • "Compare LLM usage patterns across workspace plans (metadata->>'plan' from workspace_subscriptions.plan_type)"

Flexible Metadata Queries:

  • "Show sessions where metadata->>'feature_flag' = 'beta_enabled'"
  • "Analyze behavior by organization: metadata->>'org_id'"
  • "Track A/B test results: metadata->>'experiment_variant'"

Implementation Examples

🎯 Quick Start: 5-Minute Setup

Step 1: Add the Provider to your app

// app/layout.tsx - just wrap your existing layout
import { AnalyticsProvider } from '@/lib/providers/analytics-provider';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AnalyticsProvider>
          {children} {/* Your existing app */}
        </AnalyticsProvider>
      </body>
    </html>
  );
}

Step 2: Add one-line tracking to any component

// Any component - just import and use
import { useAnalytics } from '@/lib/providers/analytics-provider';

export function MyComponent() {
  const analytics = useAnalytics();

  return (
    <Button onClick={() => analytics.trackGoalEvent('button_clicked')}>
      Click Me
    </Button>
  );
}

Step 3: Add to your chat (3 lines)

// Your existing useChat - just add onFinish callback
const { messages, input, handleSubmit } = useChat({
  api: '/api/chat',
  onFinish: (message, options) => {
    analytics.trackLLMUsage({
      model: 'gpt-4o',
      tokens: options.usage?.totalTokens || 0,
      cost: (options.usage?.totalTokens || 0) * 0.005
    });
  }
});

That's it! You're now tracking user sessions, goals, and LLM usage.


Full Implementation Details

1. Analytics Provider (React Context)

// lib/providers/analytics-provider.tsx
"use client";

import { createContext, useContext, useEffect, useRef, ReactNode } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { trackSessionAction } from "@/app/actions/analytics-actions";
import type { Session } from "@supabase/supabase-js";

// Time (in minutes) when to write and flush session data to the server
const FLUSH_INTERVAL = 5; // minutes
// Simple batch rotation guard
const MAX_PATHS_PER_BATCH = 50;
// Client debug logging flag
const CLIENT_ANALYTICS_DEBUG =
  process.env.NEXT_PUBLIC_ANALYTICS_DEBUG === "true";
const getBatchStateKey = (sessionId: string, batchSeq: number) =>
  `analytics_batch_state:${sessionId}:${batchSeq}`;

interface SessionData {
  sessionId: string;
  batchId: string;
  batchSeq: number;
  userId: string | null;
  metadata: Record<string, any>; // flexible app-specific context
  startedAt: Date;
  pathSequence: string[];
  pathTimings: number[];
  interactionSummary: Record<string, number>;
  goalEvents: Record<string, string>;
  featureInteractions: Record<string, number>;
  pageLoadTimes: Record<string, number>;
  errorEvents: Array<{
    type: string;
    page: string;
    time: number;
    message?: string;
  }>;
  llmInteractions: Array<{
    model: string;
    tokens: number;
    cost: number;
    timestamp: string;
  }>;
}

// Base64URL-safe decode for JWT payloads (browser-friendly)
const decodeBase64Url = (input: string) => {
  const base64 =
    input.replace(/-/g, "+").replace(/_/g, "/") +
    "===".slice((input.length + 3) % 4);
  return atob(base64);
};

// Extract session_id from Supabase JWT without external dependencies
const extractSessionId = (session: Session | null): string | undefined => {
  const token = session?.access_token;
  if (!token) return;
  try {
    const payload = token.split(".")[1];
    if (!payload) return;
    const json = JSON.parse(decodeBase64Url(payload));
    return json.session_id;
  } catch {
    return undefined;
  }
};

interface AnalyticsContextType {
  trackGoalEvent: (event: string) => void;
  trackInteraction: (elementType: string) => void;
  trackFeatureUse: (feature: string) => void;
  trackPageLoad: (page: string, loadTime: number) => void;
  trackError: (error: { type: string; message?: string }) => void;
  trackLLMUsage: (data: {
    model: string;
    tokens: number;
    cost: number;
    responseTime?: number;
    promptTokens?: number;
    completionTokens?: number;
    stopped?: boolean;
    chatId?: string;
    messageId?: string;
  }) => void;
}

const AnalyticsContext = createContext<AnalyticsContextType | undefined>(
  undefined
);

export function AnalyticsProvider({ children }: { children: ReactNode }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const supabase = createClient();
  const sessionRef = useRef<SessionData | null>(null);
  const sessionStartTime = useRef<number>(Date.now());
  const batchSeqRef = useRef<number>(0);
  const isDirtyRef = useRef<boolean>(false);
  // No component state here to avoid unnecessary re-renders

  // Initialize session (mount-only)
  useEffect(() => {
    const initSession = async () => {
      const {
        data: { session },
      } = await supabase.auth.getSession();
      const {
        data: { user },
      } = await supabase.auth.getUser();

      // Extract the real Supabase session_id from JWT
      const supabaseSessionId = extractSessionId(session);

      // For analytics, handle both authenticated and anonymous users
      const analyticsSessionId =
        supabaseSessionId ||
        sessionStorage.getItem("anon_session_id") ||
        `anon-${crypto.randomUUID()}`;

      // Store anonymous session ID for consistency
      if (!supabaseSessionId && !sessionStorage.getItem("anon_session_id")) {
        sessionStorage.setItem("anon_session_id", analyticsSessionId);
      }

      // Load persisted batch sequence for this session
      const batchSeqKey = `analytics_batch_seq:${analyticsSessionId}`;
      const storedSeqRaw = sessionStorage.getItem(batchSeqKey);
      const storedSeq = storedSeqRaw ? parseInt(storedSeqRaw, 10) || 0 : 0;
      batchSeqRef.current = storedSeq;

      // Get current context from your existing app state
      // This is just an example - adapt to your app's context management

      // Example: Get workspace with subscription data
      // const currentWorkspace = null; // Replace with your workspace logic
      // const { data: workspace } = await supabase
      //   .from('workspaces')
      //   .select('*, workspace_subscriptions(plan_type)')
      //   .eq('id', workspaceId)
      //   .single();

      // const appMetadata = {
      // workspace_id: currentWorkspace?.id || null,
      // project_id: null, // Replace with your project logic
      // team_id: null, // Replace with your team logic
      // plan: currentWorkspace?.workspace_subscriptions?.plan_type || "free",
      // Store Supabase session_id for correlation with auth.sessions table
      // supabase_session_id: supabaseSessionId || null,
      // };

      // Try to restore persisted batch state if it exists
      const stateKey = getBatchStateKey(analyticsSessionId, batchSeqRef.current);
      let restoredState = null;
      try {
        const storedState = sessionStorage.getItem(stateKey);
        if (storedState) {
          restoredState = JSON.parse(storedState);
          if (CLIENT_ANALYTICS_DEBUG) {
            console.log("[analytics] restored batch state", {
              sessionId: analyticsSessionId,
              batchSeq: batchSeqRef.current,
              paths: restoredState.pathSequence?.length || 0,
            });
          }
        }
      } catch {}

      sessionRef.current = {
        sessionId: analyticsSessionId, // Stable session ID for analytics
        batchId: crypto.randomUUID(),
        batchSeq: batchSeqRef.current,
        userId: user?.id || null,
        // metadata: appMetadata,
        metadata: restoredState?.metadata || {},
        startedAt: new Date(),
        // Restore arrays if they exist, otherwise start with current page
        pathSequence: restoredState?.pathSequence || [pathname],
        pathTimings: restoredState?.pathTimings || [0],
        interactionSummary: restoredState?.interactionSummary || {},
        goalEvents: restoredState?.goalEvents || {},
        featureInteractions: restoredState?.featureInteractions || {},
        pageLoadTimes: restoredState?.pageLoadTimes || {},
        errorEvents: restoredState?.errorEvents || [],
        llmInteractions: restoredState?.llmInteractions || [],
      };

      // Ensure a value exists for this session's batch seq in storage
      if (!storedSeqRaw) {
        sessionStorage.setItem(batchSeqKey, String(batchSeqRef.current));
      }

      if (CLIENT_ANALYTICS_DEBUG) {
        // Minimal init log
        // eslint-disable-next-line no-console
        console.log("[analytics] init", {
          sessionId: analyticsSessionId,
          batchSeq: batchSeqRef.current,
          entry: pathname,
        });
      }
    };

    initSession();

    // Listen for auth state changes to update session tracking
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((event, session) => {
      if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
        const newSessionId = extractSessionId(session);
        if (newSessionId && sessionRef.current) {
          sessionRef.current.sessionId = newSessionId;
          sessionRef.current.userId = session?.user?.id || null;
          sessionStorage.removeItem("anon_session_id");
          // Load persisted batch sequence for the new session id
          const key = `analytics_batch_seq:${newSessionId}`;
          const stored = sessionStorage.getItem(key);
          const seq = stored ? parseInt(stored, 10) || 0 : 0;
          batchSeqRef.current = seq;
          sessionRef.current.batchSeq = seq;
          if (!stored) sessionStorage.setItem(key, String(seq));
        }
      } else if (event === "SIGNED_OUT") {
        const anonSessionId = `anon-${crypto.randomUUID()}`;
        sessionStorage.setItem("anon_session_id", anonSessionId);
        if (sessionRef.current) {
          sessionRef.current.sessionId = anonSessionId;
          sessionRef.current.userId = null;
          // Reset batch seq for a new anon session id
          const key = `analytics_batch_seq:${anonSessionId}`;
          batchSeqRef.current = 0;
          sessionRef.current.batchSeq = 0;
          sessionStorage.setItem(key, "0");
        }
      }
    });

    return () => {
      subscription.unsubscribe();
    };
  }, [supabase]);

  // Track page navigation (pathname + search)
  useEffect(() => {
    if (!sessionRef.current) return;

    const currentTime = Date.now() - sessionStartTime.current;
    const search = searchParams?.toString();
    const pathWithSearch = search ? `${pathname}?${search}` : pathname;
    const lastPath =
      sessionRef.current.pathSequence[
        sessionRef.current.pathSequence.length - 1
      ];

    if (lastPath !== pathWithSearch) {
      sessionRef.current.pathSequence.push(pathWithSearch);
      sessionRef.current.pathTimings.push(currentTime);
      isDirtyRef.current = true;

      // Batch update session data every 5 page changes
      if (sessionRef.current.pathSequence.length % 5 === 0) {
        flushSessionData();
      }

      if (CLIENT_ANALYTICS_DEBUG) {
        // eslint-disable-next-line no-console
        console.log("[analytics] nav", {
          path: pathWithSearch,
          totalPaths: sessionRef.current.pathSequence.length,
        });
      }
    }
  }, [pathname, searchParams]);

  // Track what we've already sent to avoid duplicates
  const lastSentPathIndex = useRef<number>(0);
  const lastSentInteractions = useRef<Record<string, number>>({});

  // Flush session data to server (sends only deltas)
  const flushSessionData = async () => {
    if (!sessionRef.current) return;

    const session = sessionRef.current;

    // Full-state snapshot for this batch (simpler and consistent upserts)
    if (!isDirtyRef.current) {
      if (CLIENT_ANALYTICS_DEBUG) {
        // eslint-disable-next-line no-console
        console.log("[analytics] flush:skip (no changes)", {
          sessionId: session.sessionId,
          batchSeq: session.batchSeq,
        });
      }
      return;
    }

    try {
      if (CLIENT_ANALYTICS_DEBUG) {
        // eslint-disable-next-line no-console
        console.log("[analytics] flush:start", {
          sessionId: session.sessionId,
          batchSeq: session.batchSeq,
          paths: session.pathSequence.length,
          interactions: Object.keys(session.interactionSummary).length,
          goals: Object.keys(session.goalEvents).length,
        });
      }
      // Persist a local snapshot before sending (safety against partial overwrites)
      try {
        const stateKey = getBatchStateKey(session.sessionId, session.batchSeq);
        sessionStorage.setItem(
          stateKey,
          JSON.stringify({
            pathSequence: session.pathSequence,
            pathTimings: session.pathTimings,
            interactionSummary: session.interactionSummary,
            goalEvents: session.goalEvents,
            featureInteractions: session.featureInteractions,
            pageLoadTimes: session.pageLoadTimes,
            errorEvents: session.errorEvents,
            llmInteractions: session.llmInteractions,
          })
        );
      } catch {}

      await trackSessionAction({
        sessionId: session.sessionId,
        batchId: session.batchId,
        batchSeq: session.batchSeq,
        userId: session.userId,
        metadata: session.metadata,
        startedAt: session.startedAt.toISOString(),
        endedAt: new Date().toISOString(),
        pathSequence: session.pathSequence,
        pathTimings: session.pathTimings,
        entryPoint: session.pathSequence[0],
        exitPoint: session.pathSequence[session.pathSequence.length - 1],
        interactionSummary: session.interactionSummary,
        goalEvents: session.goalEvents,
        featureInteractions: session.featureInteractions,
        pageLoadTimes: session.pageLoadTimes,
        errorEvents: session.errorEvents,
        llmInteractions: session.llmInteractions,
        llmTokenUsage: session.llmInteractions.reduce(
          (sum, llm) => sum + llm.tokens,
          0
        ),
        llmCostEstimate: session.llmInteractions.reduce(
          (sum, llm) => sum + llm.cost,
          0
        ),
        sessionType: determineSessionType(),
        conversionAchieved: Object.keys(session.goalEvents).length > 0,
      });

      // Update what we've sent
      lastSentPathIndex.current = session.pathSequence.length;
      lastSentInteractions.current = { ...session.interactionSummary };

      // Clean up old data from memory to prevent unbounded growth
      if (session.errorEvents.length > 20) {
        session.errorEvents = session.errorEvents.slice(-20);
      }
      if (session.llmInteractions.length > 10) {
        session.llmInteractions = session.llmInteractions.slice(-10);
      }

      // Only rotate batch when we actually hit the size limit AND this flush succeeded
      // Don't rotate on goal events, periodic flushes, or other arbitrary flushes
      const shouldRotateBySize = session.pathSequence.length >= MAX_PATHS_PER_BATCH;

      if (shouldRotateBySize) {
        if (CLIENT_ANALYTICS_DEBUG) {
          console.log("[analytics] rotating batch", {
            sessionId: session.sessionId,
            oldBatchSeq: session.batchSeq,
            pathCount: session.pathSequence.length,
          });
        }

        const lastPath = session.pathSequence[session.pathSequence.length - 1] || "/";

        // Save current state to sessionStorage before rotating
        const oldBatchKey = getBatchStateKey(session.sessionId, session.batchSeq);
        try {
          sessionStorage.setItem(oldBatchKey, JSON.stringify({
            pathSequence: session.pathSequence,
            pathTimings: session.pathTimings,
            interactionSummary: session.interactionSummary,
            goalEvents: session.goalEvents,
            featureInteractions: session.featureInteractions,
            pageLoadTimes: session.pageLoadTimes,
            errorEvents: session.errorEvents,
            llmInteractions: session.llmInteractions,
            rotated: true // Mark as rotated
          }));
        } catch {}

        // Move to a fresh batch carrying the last known path as entry
        batchSeqRef.current += 1;
        const key = `analytics_batch_seq:${session.sessionId}`;
        sessionStorage.setItem(key, String(batchSeqRef.current));
        sessionRef.current.batchId = crypto.randomUUID();
        sessionRef.current.batchSeq = batchSeqRef.current;
        sessionRef.current.startedAt = new Date();
        sessionStartTime.current = Date.now();

        // Reset per-batch accumulators for the new batch
        sessionRef.current.pathSequence = [lastPath];
        sessionRef.current.pathTimings = [0];
        sessionRef.current.pageLoadTimes = {};
        sessionRef.current.errorEvents = [];
        sessionRef.current.llmInteractions = [];
        // IMPORTANT: keep cumulative maps so we don't lose prior values
        // sessionRef.current.interactionSummary stays as-is
        // sessionRef.current.goalEvents stays as-is
        // sessionRef.current.featureInteractions stays as-is

        // Reset send trackers
        lastSentPathIndex.current = sessionRef.current.pathSequence.length;
        lastSentInteractions.current = {};
      }
      if (CLIENT_ANALYTICS_DEBUG) {
        // eslint-disable-next-line no-console
        console.log("[analytics] flush:ok", {
          sessionId: session.sessionId,
          batchSeq: session.batchSeq,
        });
      }
      isDirtyRef.current = false;
    } catch (error) {
      console.error("Failed to track session:", error);
    }
  };

  // Determine session type based on behavior
  const determineSessionType = (): string => {
    if (!sessionRef.current) return "standard";

    const { goalEvents, pathSequence, interactionSummary } = sessionRef.current;
    const totalInteractions = Object.values(interactionSummary).reduce(
      (sum, count) => sum + count,
      0
    );

    if (Object.keys(goalEvents).length > 0) return "conversion";
    if (pathSequence.length === 1 && totalInteractions < 2) return "bounce";
    if (pathSequence.length > 5) return "exploration";
    return "standard";
  };

  // Analytics tracking functions
  const trackGoalEvent = (event: string) => {
    if (!sessionRef.current) return;

    sessionRef.current.goalEvents[event] = new Date().toISOString();
    isDirtyRef.current = true;
    // Immediately flush goal events
    flushSessionData();
  };

  const trackInteraction = (elementType: string) => {
    if (!sessionRef.current) return;

    sessionRef.current.interactionSummary[elementType] =
      (sessionRef.current.interactionSummary[elementType] || 0) + 1;
    isDirtyRef.current = true;
  };

  const trackFeatureUse = (feature: string) => {
    if (!sessionRef.current) return;

    sessionRef.current.featureInteractions[feature] =
      (sessionRef.current.featureInteractions[feature] || 0) + 1;
    isDirtyRef.current = true;
  };

  const trackPageLoad = (page: string, loadTime: number) => {
    if (!sessionRef.current) return;

    sessionRef.current.pageLoadTimes[page] = loadTime;
    isDirtyRef.current = true;
  };

  const trackError = (error: { type: string; message?: string }) => {
    if (!sessionRef.current) return;

    sessionRef.current.errorEvents.push({
      type: error.type,
      page: pathname,
      time: Date.now() - sessionStartTime.current,
      message: error.message,
    });
    isDirtyRef.current = true;
  };

  const trackLLMUsage = (data: {
    model: string;
    tokens: number;
    cost: number;
    responseTime?: number;
    promptTokens?: number;
    completionTokens?: number;
    stopped?: boolean;
    chatId?: string;
    messageId?: string;
  }) => {
    if (!sessionRef.current) return;

    sessionRef.current.llmInteractions.push({
      model: data.model,
      tokens: data.tokens,
      cost: data.cost,
      timestamp: new Date().toISOString(),
    });
    isDirtyRef.current = true;
  };

  // Flush data when page is hidden/unloaded (Blob + beacon)
  useEffect(() => {
    const flushOnHide = () => {
      if (!sessionRef.current) return;
      const payload = new Blob([JSON.stringify(sessionRef.current)], {
        type: "application/json",
      });
      if (CLIENT_ANALYTICS_DEBUG) {
        // eslint-disable-next-line no-console
        console.log("[analytics] beacon:send", {
          sessionId: sessionRef.current.sessionId,
          batchSeq: sessionRef.current.batchSeq,
          paths: sessionRef.current.pathSequence.length,
        });
      }
      navigator.sendBeacon("/api/analytics/track", payload);
    };

    const handleVisibilityChange = () => {
      if (document.visibilityState === "hidden") flushOnHide();
    };

    window.addEventListener("pagehide", flushOnHide);
    document.addEventListener("visibilitychange", handleVisibilityChange);

    return () => {
      window.removeEventListener("pagehide", flushOnHide);
      document.removeEventListener("visibilitychange", handleVisibilityChange);
    };
  }, []);

  // Periodic flush (every FLUSH_INTERVAL minutes)
  useEffect(() => {
    const interval = setInterval(flushSessionData, FLUSH_INTERVAL * 60 * 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <AnalyticsContext.Provider
      value={{
        trackGoalEvent,
        trackInteraction,
        trackFeatureUse,
        trackPageLoad,
        trackError,
        trackLLMUsage,
      }}
    >
      {children}
    </AnalyticsContext.Provider>
  );
}

export const useAnalytics = () => {
  const context = useContext(AnalyticsContext);
  if (context === undefined) {
    throw new Error("useAnalytics must be used within an AnalyticsProvider");
  }
  return context;
};

2. Server Action for Database Writes

// app/actions/analytics-actions.ts
'use server';

import { createServiceRoleClient } from '@/lib/supabase/service-role';

interface SessionTrackingData {
  sessionId: string;
  batchId: string;
  batchSeq: number;
  userId: string | null;
  metadata: Record<string, any>;
  startedAt: string;
  endedAt: string;
  pathSequence: string[];
  pathTimings: number[];
  entryPoint: string;
  exitPoint: string;
  interactionSummary: Record<string, number>;
  goalEvents: Record<string, string>;
  featureInteractions: Record<string, number>;
  pageLoadTimes: Record<string, number>;
  errorEvents: Array<{ type: string; page: string; time: number; message?: string }>;
  llmInteractions: Array<{ model: string; tokens: number; cost: number; timestamp: string }>;
  llmTokenUsage: number;
  llmCostEstimate: number;
  sessionType: string;
  conversionAchieved: boolean;
}

export async function trackSessionAction(data: SessionTrackingData) {
  try {
    const supabase = createServiceRoleClient();

    // Minimal server-side debug (can be toggled later if needed)
    if (process.env.ANALYTICS_DEBUG === 'true') {
      console.log('[analytics] upsert', {
        sessionId: data.sessionId,
        batchSeq: data.batchSeq,
        paths: data.pathSequence?.length,
        interactions: Object.keys(data.interactionSummary || {}).length,
      });
    }

    // Upsert per session (update same row by session_id)
    const { error } = await supabase
      .from('analytics_user_sessions')
      .upsert({
        session_id: data.sessionId,
        batch_id: data.batchId,
        batch_seq: data.batchSeq,
        user_id: data.userId,
        metadata: data.metadata,
        started_at: data.startedAt,
        ended_at: data.endedAt,
        path_sequence: data.pathSequence,
        path_timings: data.pathTimings,
        entry_point: data.entryPoint,
        exit_point: data.exitPoint,
        interaction_summary: data.interactionSummary,
        goal_events: data.goalEvents,
        feature_interactions: data.featureInteractions,
        page_load_times: data.pageLoadTimes,
        error_events: data.errorEvents,
        llm_interactions: data.llmInteractions,
        llm_token_usage: data.llmTokenUsage,
        llm_cost_estimate: data.llmCostEstimate,
        session_type: data.sessionType,
        conversion_achieved: data.conversionAchieved,
        updated_at: new Date().toISOString()
      }, {
        onConflict: 'session_id,batch_seq',
        ignoreDuplicates: false
      });

    if (error) {
      console.error('Analytics tracking error:', error);
      throw error;
    }

    return { success: true };
  } catch (error) {
    console.error('Failed to track session:', error);
    throw error;
  }
}

3. Analytics Hook for Components

// hooks/use-analytics-tracker.ts
import { useAnalytics } from '@/lib/providers/analytics-provider';
import { useEffect } from 'react';

export function useAnalyticsTracker() {
  const analytics = useAnalytics();

  // Auto-track page performance
  useEffect(() => {
    const startTime = performance.now();
    
    const handleLoad = () => {
      const loadTime = performance.now() - startTime;
      analytics.trackPageLoad(window.location.pathname, Math.round(loadTime));
    };

    if (document.readyState === 'complete') {
      handleLoad();
    } else {
      window.addEventListener('load', handleLoad);
      return () => window.removeEventListener('load', handleLoad);
    }
  }, [analytics]);

  // Auto-track JavaScript errors
  useEffect(() => {
    const handleError = (event: ErrorEvent) => {
      analytics.trackError({
        type: 'javascript_error',
        message: event.message
      });
    };

    const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
      analytics.trackError({
        type: 'unhandled_promise_rejection',
        message: String(event.reason)
      });
    };

    window.addEventListener('error', handleError);
    window.addEventListener('unhandledrejection', handleUnhandledRejection);

    return () => {
      window.removeEventListener('error', handleError);
      window.removeEventListener('unhandledrejection', handleUnhandledRejection);
    };
  }, [analytics]);

  return analytics;
}

When to use this hook

  • Use only under AnalyticsProvider. It auto-captures page performance and JS errors for targeted routes without wiring per-component events.
  • Useful when you want auto-instrumentation enabled only for specific route groups (e.g., workspace or editor surfaces) while keeping other areas quiet.

Example usage: mount auto-tracking for a route group

// components/analytics-tracker.tsx
'use client';
import { useAnalyticsTracker } from '@/hooks/use-analytics-tracker';

export function AnalyticsTracker() {
  useAnalyticsTracker();
  return null;
}
// app/(workspace)/layout.tsx
import { ReactNode } from 'react';
import { AnalyticsProvider } from '@/lib/providers/analytics-provider';
import { AnalyticsTracker } from '@/components/analytics-tracker';

export default function WorkspaceLayout({ children }: { children: ReactNode }) {
  return (
    <AnalyticsProvider>
      <AnalyticsTracker />
      {children}
    </AnalyticsProvider>
  );
}

4. Component Usage Examples

// components/example-usage.tsx
import { useAnalytics } from '@/lib/providers/analytics-provider';
import { Button } from '@/components/ui/button';

export function WorkspaceCreationForm() {
  const analytics = useAnalytics();

  const handleCreateWorkspace = async () => {
    analytics.trackInteraction('btn_create_workspace');
    
    try {
      // Your workspace creation logic
      await createWorkspace();
      
      // Track successful goal event
      analytics.trackGoalEvent('workspace_created');
      analytics.trackFeatureUse('workspace_management');
      
    } catch (error) {
      analytics.trackError({
        type: 'workspace_creation_failed',
        message: error.message
      });
    }
  };

  return (
    <Button onClick={handleCreateWorkspace}>
      Create Workspace
    </Button>
  );
}

// Example: Chat component with LLM tracking using Vercel AI SDK v5
export function ChatInterface({ chatId }: { chatId: string }) {
  const analytics = useAnalytics();
  const [messageStartTime, setMessageStartTime] = useState<number | null>(null);

  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    sendMessage,
    status,
    error
  } = useChat({
    transport: new DefaultChatTransport({
      api: `/api/chat/${chatId}`,
    }),
    id: chatId,
    onRequest: (options) => {
      // Track when message request starts
      setMessageStartTime(Date.now());
      analytics.trackInteraction('chat_message_sent');
      analytics.trackFeatureUse('chat');
      
      return options;
    },
    onResponse: (response) => {
      // Track response received
      if (messageStartTime) {
        const responseTime = Date.now() - messageStartTime;
        analytics.trackPageLoad('chat_response', responseTime);
      }
      
      return response;
    },
    onFinish: (message, options) => {
      // Track LLM usage when message completes
      if (messageStartTime) {
        const totalTime = Date.now() - messageStartTime;
        
        // Extract usage data from the response (if available in your API)
        const usage = options.usage || message.experimental_providerMetadata?.usage;
        
        if (usage) {
          analytics.trackLLMUsage({
            model: options.model || 'unknown',
            tokens: usage.totalTokens || usage.total_tokens || 0,
            cost: calculateCost(usage.totalTokens || usage.total_tokens || 0, options.model || 'gpt-4'),
            responseTime: totalTime,
            promptTokens: usage.promptTokens || usage.prompt_tokens || 0,
            completionTokens: usage.completionTokens || usage.completion_tokens || 0
          });
        }

        // Track successful completion
        analytics.trackGoalEvent('chat_interaction_completed');
        setMessageStartTime(null);
      }
    },
    onError: (error) => {
      // Track chat errors
      analytics.trackError({
        type: 'chat_error',
        message: error.message || 'Chat request failed'
      });
      setMessageStartTime(null);
    },
  });

  // Track message regeneration
  const handleRegenerate = () => {
    analytics.trackInteraction('chat_message_regenerated');
    analytics.trackFeatureUse('chat_regenerate');
    setMessageStartTime(Date.now());
    // Your regenerate logic here
  };

  // Track when user stops generation
  const handleStop = () => {
    analytics.trackInteraction('chat_generation_stopped');
    if (messageStartTime) {
      const partialTime = Date.now() - messageStartTime;
      analytics.trackLLMUsage({
        model: 'unknown',
        tokens: 0, // Partial generation - no final token count
        cost: 0,
        responseTime: partialTime,
        stopped: true
      });
      setMessageStartTime(null);
    }
  };

  return (
    <div>
      <div>
        {messages.map((message) => (
          <div key={message.id}>
            {message.role === 'user' ? 'You: ' : 'AI: '}
            {message.content}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type your message..."
          disabled={status === 'in_progress'}
        />
        <button type="submit" disabled={status === 'in_progress'}>
          {status === 'in_progress' ? 'Sending...' : 'Send'}
        </button>
        {status === 'in_progress' && (
          <button type="button" onClick={handleStop}>
            Stop
          </button>
        )}
      </form>
    </div>
  );
}

5. Simple LLM Tracking with Existing useChat

Option A: Just Add Callbacks to Your Existing useChat

// In your existing chat component - just add these callbacks
export function ChatInterface({ chatId }: { chatId: string }) {
  const analytics = useAnalytics();

  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    status,
    error
  } = useChat({
    transport: new DefaultChatTransport({
      api: `/api/chat/${chatId}`,
    }),
    id: chatId,
    // Just add these 3 callbacks - that's it!
    onRequest: () => {
      analytics.trackInteraction('chat_message_sent');
      analytics.trackFeatureUse('chat');
    },
    onFinish: (message, options) => {
      // Track LLM usage when available
      const usage = options.usage || message.experimental_providerMetadata?.usage;
      if (usage) {
        analytics.trackLLMUsage({
          model: 'gpt-4o', // or get from options
          tokens: usage.totalTokens || 0,
          cost: (usage.totalTokens || 0) * 0.005 // rough estimate
        });
      }
      analytics.trackGoalEvent('chat_interaction_completed');
    },
    onError: (error) => {
      analytics.trackError({
        type: 'chat_error',
        message: error.message
      });
    },
  });

  return (
    <div>
      {/* Your existing chat UI - no changes needed */}
      <div>
        {messages.map((message) => (
          <div key={message.id}>{message.content}</div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Option B: Track from Your API Route (Even Simpler)

// app/api/chat/[chatId]/route.ts
// Add analytics tracking right in your existing API route (append-only batch insert)
// Ensure the client sends 'x-analytics-session-id' header from your AnalyticsProvider

export async function POST(req: Request, { params }: { params: Promise<{ chatId: string }> }) {
  const { chatId } = await params;
  const { messages } = await req.json();
  const analyticsSessionId = req.headers.get('x-analytics-session-id') || undefined;
  
  // Your existing streamText call
  const result = streamText({
    model: openai('gpt-4o'),
    messages,
    // Add this callback to track usage (insert-only batch row)
    onFinish: ({ usage, finishReason, response }) => {
      if (!analyticsSessionId) return; // don't misuse chatId as session_id
      // Track LLM usage server-side (fire and forget)
      trackLLMUsageAsync({
        batchId: crypto.randomUUID(),
        batchSeq: Date.now(), // simple monotonic value for ordering
        analyticsSessionId,
        chatId,
        model: 'gpt-4o',
        tokens: usage.totalTokens,
        cost: calculateCost(usage.totalTokens, 'gpt-4o'),
        finishReason,
        statusCode: response?.status
      }).catch(console.error);
    }
  });

  return result.toUIMessageStreamResponse();
}

// Simple async function to track without blocking response (append-only)
async function trackLLMUsageAsync(data: any) {
  const supabase = createServiceRoleClient();
  await supabase.from('analytics_user_sessions').insert({
    session_id: data.analyticsSessionId,
    batch_id: data.batchId,
    batch_seq: data.batchSeq,
    llm_interactions: [
      {
        chatId: data.chatId,
        model: data.model,
        tokens: data.tokens,
        cost: data.cost,
        finishReason: data.finishReason,
        statusCode: data.statusCode,
        timestamp: new Date().toISOString()
      }
    ],
    metadata: { 
      chat_id: data.chatId
    },
    updated_at: new Date().toISOString()
  }, { onConflict: 'batch_id', ignoreDuplicates: true });
}

Option C: One-Line Button Tracking

// Just add onClick tracking to any button - super simple
export function SomeComponent() {
  const analytics = useAnalytics();

  return (
    <div>
      <Button 
        onClick={() => {
          analytics.trackInteraction('workspace_created');
          analytics.trackGoalEvent('workspace_created');
          // Your existing onClick logic
        }}
      >
        Create Workspace
      </Button>
      
      <Button 
        onClick={() => {
          analytics.trackInteraction('chat_shared');
          // Your existing logic
        }}
      >
        Share Chat
      </Button>
    </div>
  );
}

6. Flexible Metadata Examples

Setting Context in Provider:

// In your AnalyticsProvider initialization
const appMetadata = {
  // Common B2B SaaS context
  workspace_id: currentWorkspace?.id,
  project_id: currentProject?.id,
  team_id: currentTeam?.id,
  org_id: currentOrg?.id,
  plan: currentWorkspace?.workspace_subscriptions?.plan_type || 'free',
  
  // Feature flags
  feature_beta_enabled: featureFlags?.betaEnabled,
  experiment_variant: abTest?.variant,
  
  // Custom app context
  user_role: user?.role,
  workspace_size: currentWorkspace?.memberCount,
  account_type: user?.accountType || 'individual'
};

Getting Workspace Plan from Database:

// Example: Fetch workspace with subscription data
const { data: workspace } = await supabase
  .from('workspaces')
  .select(`
    id,
    name,
    workspace_subscriptions (
      plan_type,
      status,
      created_at
    )
  `)
  .eq('id', workspaceId)
  .single();

const appMetadata = {
  workspace_id: workspace?.id,
  plan: workspace?.workspace_subscriptions?.plan_type || 'free',
  subscription_status: workspace?.workspace_subscriptions?.status || 'inactive'
};

Querying with Metadata (LLM-friendly):

-- Find all sessions for a specific workspace
SELECT * FROM analytics_session_summary 
WHERE workspace_id = 'workspace_123';

-- Compare conversion rates by plan
SELECT 
  metadata->>'plan' as plan,
  COUNT(*) as total_sessions,
  COUNT(*) FILTER (WHERE conversion_achieved) as conversions,
  (COUNT(*) FILTER (WHERE conversion_achieved)::float / COUNT(*)) * 100 as conversion_rate
FROM analytics_user_sessions 
GROUP BY metadata->>'plan';

-- Analyze feature adoption by team size
SELECT 
  CASE 
    WHEN (metadata->>'workspace_size')::int <= 5 THEN 'small'
    WHEN (metadata->>'workspace_size')::int <= 20 THEN 'medium'
    ELSE 'large'
  END as team_size,
  AVG(jsonb_array_length(feature_interactions)) as avg_features_used
FROM analytics_user_sessions 
WHERE metadata->>'workspace_size' IS NOT NULL
GROUP BY 1;

-- Track A/B test performance
SELECT 
  metadata->>'experiment_variant' as variant,
  AVG(session_duration_ms) as avg_session_duration,
  COUNT(*) FILTER (WHERE conversion_achieved) as conversions
FROM analytics_user_sessions 
WHERE metadata ? 'experiment_variant'
GROUP BY metadata->>'experiment_variant';

7. API Route for Beacon Tracking

// app/api/analytics/track/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createServiceRoleClient } from '@/lib/supabase/service-role';
export const dynamic = 'force-dynamic';

export async function POST(request: NextRequest) {
  try {
    const data = await request.json();
    const supabase = createServiceRoleClient();

    // Append-only beacon batch (idempotent by batch_id)
    const { error } = await supabase
      .from('analytics_user_sessions')
      .upsert({
        session_id: data.sessionId,
        batch_id: data.batchId || crypto.randomUUID(),
        batch_seq: data.batchSeq,
        user_id: data.userId,
        metadata: data.metadata,
        started_at: data.startedAt || new Date().toISOString(),
        ended_at: new Date().toISOString(),
        path_sequence: data.pathSequence,
        path_timings: data.pathTimings,
        entry_point: Array.isArray(data.pathSequence) && data.pathSequence.length > 0 ? data.pathSequence[0] : null,
        exit_point: Array.isArray(data.pathSequence) && data.pathSequence.length > 0 ? data.pathSequence[data.pathSequence.length - 1] : null,
        interaction_summary: data.interactionSummary,
        goal_events: data.goalEvents,
        updated_at: new Date().toISOString()
      }, { onConflict: 'session_id,batch_seq', ignoreDuplicates: false });

    if (error) throw error;

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Beacon tracking error:', error);
    return NextResponse.json({ error: 'Failed to track' }, { status: 500 });
  }
}

--

Changelog

Updates Made

Based on production feedback, the following critical fixes have been implemented:

  1. ✅ Session ID Fix: Now properly extracts session_id UUID from Supabase JWT claims instead of slicing access token
  2. ✅ Data Loss Prevention: Server action now merges arrays/objects properly instead of overwriting them
  3. ✅ Memory Management: Provider sends deltas and caps array sizes to prevent unbounded growth
  4. ✅ Database Constraints: Added UNIQUE constraint on session_id and array size limits
  5. ✅ Anonymous Support: Handles both authenticated (Supabase session_id) and anonymous users seamlessly
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment