diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000..0c05c37 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,51 @@ +# Analytics + +Mumbl uses opt-in Umami analytics for early product learning. Analytics must stay aggregate and privacy-safe: the product should help us understand whether people find and try Mumbl, not make hesitation, lurking, or anonymous room behavior observable. + +## What We Track + +- Public page views for `/`, `/create`, and `/explore`. +- Coarse public-page scroll milestones at 50% and 90%. +- Major public CTAs such as waitlist anchors, demo entry, create-room entry, email, Twitter, and Calendly links. +- Explicit conversion outcomes: waitlist submitted or failed, space created or failed. +- Explicit room actions that already change product state: share copied, first-post prompt dismissed, post created, reaction toggled, side quest card actions, and public-space setting saved. +- Sanitized campaign context on explicit conversion-style events only: `utm_source`, `utm_medium`, `utm_campaign`, and external referrer origin. + +## What We Do Not Track + +- Room opens, visitors, members, joins, lurkers, presence, tab views, reads, or load-older pagination. +- Room slug, room name, post content, display name, email, session token, creator token, side quest message text, or any user-entered private text. +- Draft starts, typing, field focus, compose abandonment, scroll replay, click maps, session replay, or per-person journeys. +- IP-derived, user-agent-derived, or fingerprint-style identifiers. + +Room URLs are masked as `/r/[space]` before any event is sent. Non-public paths are collapsed to `/app` unless they match the room mask. + +## Environment Setup + +Analytics is off unless both the feature flag and website id are present: + +```bash +NEXT_PUBLIC_ENABLE_ANALYTICS=true +NEXT_PUBLIC_UMAMI_SRC=https://your-umami.example/script.js +NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-website-id +``` + +Recommended environment posture: + +- local: disabled +- preview: optional +- production: enabled + +The Umami script is loaded with auto tracking disabled. Page views and events are sent through `src/lib/analytics.js` so the privacy filters stay centralized. + +## Early Learning Dashboard + +Use Umami to watch aggregate questions, not people: + +- Are people reaching the landing page from useful sources? +- Which public CTAs are getting clicked? +- Do visitors try the demo before joining the waitlist or creating a room? +- Which create-page vibes are selected before successful space creation? +- Are explicit room actions happening after creation: share copied, first post, reactions? + +Do not build dashboards that answer who opened a room, how many people lurked, what someone almost typed, or whether a specific space had silent visitors. For Mumbl, posts and reactions are the signal of a living room. diff --git a/docs/environments.md b/docs/environments.md index 29814da..a578346 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -98,6 +98,8 @@ Analytics should be opt-in per environment: - preview: optional - production: enabled +See `docs/analytics.md` for the tracked event boundary, Umami setup, and the privacy rules that keep analytics aggregate rather than person-level. + ## Important rule Treat `main` + Vercel Production + production Supabase as one lane. diff --git a/src/components/AnalyticsTracker.jsx b/src/components/AnalyticsTracker.jsx index 72c72b7..d3b48b9 100644 --- a/src/components/AnalyticsTracker.jsx +++ b/src/components/AnalyticsTracker.jsx @@ -2,13 +2,14 @@ import { usePathname } from "next/navigation"; import { useEffect } from "react"; -import { trackPublicPageView } from "../lib/analytics"; +import { trackPublicPageView, trackPublicScrollMilestones } from "../lib/analytics"; export default function AnalyticsTracker() { const pathname = usePathname(); useEffect(() => { trackPublicPageView(pathname); + return trackPublicScrollMilestones(pathname); }, [pathname]); return null; diff --git a/src/components/CreatePageClient.jsx b/src/components/CreatePageClient.jsx index 20a2070..f0f081a 100644 --- a/src/components/CreatePageClient.jsx +++ b/src/components/CreatePageClient.jsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { createRemoteSpace } from "../lib/api"; -import { trackEvent } from "../lib/analytics"; +import { trackConversionEvent, trackEvent } from "../lib/analytics"; import { vibes } from "../lib/constants"; import Toast from "./Toast"; @@ -23,10 +23,10 @@ export default function CreatePageClient() { setToast(""); try { const { slug } = await createRemoteSpace({ name, vibe: selectedVibe }); - trackEvent("space_created", { vibe: selectedVibe }); + trackConversionEvent("space_created", { vibe: selectedVibe }); router.push(`/r/${slug}`); } catch (error) { - trackEvent("space_create_failed", { vibe: selectedVibe }); + trackConversionEvent("space_create_failed", { vibe: selectedVibe }); setToast(error.message || "couldn't create that mumbl yet."); setIsCreating(false); } @@ -62,7 +62,10 @@ export default function CreatePageClient() { className={`pill-button ${selectedVibe === key ? "active" : ""}`} type="button" key={key} - onClick={() => setSelectedVibe(key)} + onClick={() => { + setSelectedVibe(key); + trackEvent("space_vibe_selected", { vibe: key }); + }} disabled={isCreating} > {vibe.label} diff --git a/src/components/HomeView.jsx b/src/components/HomeView.jsx index 06cddd3..d14f305 100644 --- a/src/components/HomeView.jsx +++ b/src/components/HomeView.jsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { useRecentSlug } from "../hooks/useRecentSlug"; import { joinWaitlist } from "../lib/api"; +import { trackConversionEvent, trackDemoEntry, trackPublicCta } from "../lib/analytics"; import { publicDemoRoom } from "../lib/constants"; import JoinModal from "./JoinModal"; @@ -113,11 +114,13 @@ export default function HomeView() { setWaitlistMessage(""); if (!email) { + trackConversionEvent("waitlist_submit_failed", { reason: "empty" }); setWaitlistStatus("error"); setWaitlistMessage("drop your email in first."); return; } if (!isValidEmail(email)) { + trackConversionEvent("waitlist_submit_failed", { reason: "invalid" }); setWaitlistStatus("error"); setWaitlistMessage("drop in a real email and we'll save your spot."); return; @@ -126,10 +129,12 @@ export default function HomeView() { setWaitlistStatus("submitting"); try { await joinWaitlist({ email }); + trackConversionEvent("waitlist_submitted", { source: "landing" }); setWaitlistEmail(""); setWaitlistStatus("success"); setWaitlistMessage("you're on it. we'll keep it useful."); } catch (error) { + trackConversionEvent("waitlist_submit_failed", { reason: "api" }); setWaitlistStatus("error"); setWaitlistMessage(error.message || "couldn't join the waitlist yet. try again in a minute."); } @@ -177,10 +182,17 @@ export default function HomeView() { ) : null}
- + trackDemoEntry("hero")}> try the demo -
@@ -329,16 +341,16 @@ export default function HomeView() {

join the waitlist for quick voice dumps, field notes, and team memory.

- + trackPublicCta("waitlist_anchor", { source: "bottom_cta" })}> join the waitlist - + trackPublicCta("create_room", { source: "bottom_cta" })}> create a room - + trackDemoEntry("bottom_cta")}> try the demo - + trackPublicCta("email_team_needs", { source: "bottom_cta" })}> tell us what your team needs
@@ -355,11 +367,11 @@ export default function HomeView() {

private dumps become team reads, so the work between docs and shipped features does not disappear.

diff --git a/src/lib/analytics.js b/src/lib/analytics.js index 40c26ad..8e2dffc 100644 --- a/src/lib/analytics.js +++ b/src/lib/analytics.js @@ -1,13 +1,34 @@ const MAX_ATTEMPTS = 12; const RETRY_DELAY_MS = 250; +const SCROLL_MILESTONES = [50, 90]; +const SAFE_DATA_KEYS = new Set([ + "action", + "anonymous", + "enabled", + "kind", + "label", + "milestone", + "opened", + "reason", + "source", + "target", + "type", + "utm_campaign", + "utm_medium", + "utm_source", + "vibe", + "referrer_origin", +]); +const BLOCKED_DATA_KEYS = /content|description|display|email|handle|message|name|post|room|session|slug|text|token/i; +const SAFE_VALUE_MAX_LENGTH = 80; -export function trackEvent(name, data = {}) { +export function trackEvent(name, data = {}, options = {}) { sendWhenReady(() => ({ website: websiteId(), url: safePath(window.location.pathname), title: safeTitle(window.location.pathname), name, - data: sanitiseEventData(data), + data: sanitiseEventData(options.includeContext ? { ...campaignContext(), ...data } : data), })); } @@ -21,8 +42,54 @@ export function trackPublicPageView(pathname) { })); } +export function trackPublicCta(target, data = {}) { + if (typeof window === "undefined" || !isPublicPath(window.location.pathname)) return; + trackEvent("public_cta_clicked", { target, ...data }, { includeContext: true }); +} + +export function trackDemoEntry(source = "unknown") { + if (typeof window === "undefined" || !isPublicPath(window.location.pathname)) return; + trackEvent("demo_entry_clicked", { source }, { includeContext: true }); +} + +export function trackConversionEvent(name, data = {}) { + trackEvent(name, data, { includeContext: true }); +} + +export function trackPublicScrollMilestones(pathname) { + if (typeof window === "undefined" || !isPublicPath(pathname)) return () => {}; + + const seen = new Set(); + let ticking = false; + + function checkScroll() { + ticking = false; + const scrollable = document.documentElement.scrollHeight - window.innerHeight; + if (scrollable <= 0) return; + + const percent = Math.round((window.scrollY / scrollable) * 100); + for (const milestone of SCROLL_MILESTONES) { + if (percent >= milestone && !seen.has(milestone)) { + seen.add(milestone); + trackEvent("public_scroll_milestone", { milestone }); + } + } + } + + function onScroll() { + if (ticking) return; + ticking = true; + window.requestAnimationFrame(checkScroll); + } + + window.addEventListener("scroll", onScroll, { passive: true }); + checkScroll(); + return () => window.removeEventListener("scroll", onScroll); +} + function sendWhenReady(makePayload, attempt = 0) { if (typeof window === "undefined") return; + if (!analyticsEnabled()) return; if (!websiteId()) return; if (window.umami?.track) { @@ -48,12 +115,49 @@ function websiteId() { return process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || ""; } +function analyticsEnabled() { + return process.env.NEXT_PUBLIC_ENABLE_ANALYTICS === "true"; +} + function isPublicPath(pathname) { return pathname === "/" || pathname === "/create" || pathname === "/explore"; } function sanitiseEventData(data) { return Object.fromEntries( - Object.entries(data).filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)), + Object.entries(data) + .filter(([key, value]) => isSafeDataKey(key) && ["string", "number", "boolean"].includes(typeof value)) + .map(([key, value]) => [key, typeof value === "string" ? value.slice(0, SAFE_VALUE_MAX_LENGTH) : value]), ); } + +function isSafeDataKey(key) { + return SAFE_DATA_KEYS.has(key) && !BLOCKED_DATA_KEYS.test(key); +} + +function campaignContext() { + if (typeof window === "undefined") return {}; + + const params = new URLSearchParams(window.location.search); + const context = {}; + for (const key of ["utm_source", "utm_medium", "utm_campaign"]) { + const value = params.get(key); + if (value) context[key] = value; + } + + const referrerOrigin = safeReferrerOrigin(); + if (referrerOrigin) context.referrer_origin = referrerOrigin; + return context; +} + +function safeReferrerOrigin() { + if (!document.referrer) return ""; + + try { + const referrer = new URL(document.referrer); + if (referrer.origin === window.location.origin) return ""; + return referrer.origin; + } catch { + return ""; + } +}