Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/components/AnalyticsTracker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/components/CreatePageClient.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);
}
Expand Down Expand Up @@ -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}
>
<strong>{vibe.label}</strong>
Expand Down
30 changes: 21 additions & 9 deletions src/components/HomeView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -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.");
}
Expand Down Expand Up @@ -177,10 +182,17 @@ export default function HomeView() {
) : null}
</form>
<div className="hero-actions">
<Link className="ghost-button button-link" href={publicDemoRoom.href}>
<Link className="ghost-button button-link" href={publicDemoRoom.href} onClick={() => trackDemoEntry("hero")}>
try the demo
</Link>
<button className="ghost-button" type="button" onClick={() => setJoinOpen(true)}>
<button
className="ghost-button"
type="button"
onClick={() => {
trackPublicCta("join_existing_room", { source: "hero" });
setJoinOpen(true);
}}
>
already have a link?
</button>
</div>
Expand Down Expand Up @@ -329,16 +341,16 @@ export default function HomeView() {
<p>join the waitlist for quick voice dumps, field notes, and team memory.</p>
</div>
<div className="cta-panel">
<a className="solid-button button-link" href="#waitlist">
<a className="solid-button button-link" href="#waitlist" onClick={() => trackPublicCta("waitlist_anchor", { source: "bottom_cta" })}>
join the waitlist
</a>
<Link className="ghost-button button-link" href="/create">
<Link className="ghost-button button-link" href="/create" onClick={() => trackPublicCta("create_room", { source: "bottom_cta" })}>
create a room
</Link>
<Link className="ghost-button button-link" href={publicDemoRoom.href}>
<Link className="ghost-button button-link" href={publicDemoRoom.href} onClick={() => trackDemoEntry("bottom_cta")}>
try the demo
</Link>
<a className="ghost-button button-link" href={teamNeedsMailHref}>
<a className="ghost-button button-link" href={teamNeedsMailHref} onClick={() => trackPublicCta("email_team_needs", { source: "bottom_cta" })}>
tell us what your team needs
</a>
</div>
Expand All @@ -355,11 +367,11 @@ export default function HomeView() {
<p>private dumps become team reads, so the work between docs and shipped features does not disappear.</p>
</div>
<nav aria-label="mumbl footer links">
<a href="https://twitter.com/lla_dawn" rel="noreferrer" target="_blank">
<a href="https://twitter.com/lla_dawn" rel="noreferrer" target="_blank" onClick={() => trackPublicCta("twitter_outbound", { source: "footer" })}>
twitter @lla_dawn
</a>
<a href={teamNeedsMailHref}>mumbl.wtf@gmail.com</a>
<a href={calendlyHref} rel="noreferrer" target="_blank">
<a href={teamNeedsMailHref} onClick={() => trackPublicCta("email_outbound", { source: "footer" })}>mumbl.wtf@gmail.com</a>
<a href={calendlyHref} rel="noreferrer" target="_blank" onClick={() => trackPublicCta("calendly_outbound", { source: "footer" })}>
book a call
</a>
</nav>
Expand Down
110 changes: 107 additions & 3 deletions src/lib/analytics.js
Original file line number Diff line number Diff line change
@@ -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),
}));
}

Expand All @@ -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) {
Expand All @@ -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 "";
}
}
Loading