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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ FlockStack Cloud counts how people use its own pages with PostHog: one project o
| --- | --- |
| `POSTHOG_KEY` | The project's public key (`phc_…`). Unset or malformed: nothing loads and nothing renders. Read when a request renders. |
| `POSTHOG_HOST` | Optional, an https address. Default `https://us.i.posthog.com`. |
| `POSTHOG_SESSION_REPLAY` | `true` turns session recording on, with every input and all text masked. Default off. The Privacy page says it is off, so change `ANALYTICS_RULE` in `lib/legal.js` before turning it on. |
| `POSTHOG_SESSION_REPLAY` | `true` turns session recording on, with every input and all text masked. Default off. The Privacy page says it is off while `SESSION_RECORDING_ON` in `lib/legal.js` is `false`: set it to `true` and fill in `SESSION_RECORDING_RETENTION` (until then the legal pages answer 404) in the same change. The marketing website has its own setting, `POSTHOG_SESSION_REPLAY=on`, which records only visitors who pressed Accept; the same constant covers both. |

**Where it loads.** Only in the layouts of FlockStack's own pages: the docs and the legal pages (a `layout.js` in each folder of `app/(site)`), sign-in, `/welcome` and the signed-in dashboard (`components/LayoutDashboard.jsx`, used by `app/(app)` and `app/(account)`). Never in the root layout, a newsletter's pages and archive (also on their own hosts), unsubscribe, confirm, keep and referral pages, the reader and comment pages, shop and subscribe pages, the recommendations directory, the OAuth consent page or the admin. `tests/analytics-routes.test.mjs` scans every route and fails when a page carries it that shouldn't, or the scrubber's list of untracked paths (`lib/marketing/analytics-privacy.js`) disagrees with the layouts. That list is a second lock: PostHog drops any event from such a page, even if the browser navigates there without a reload. The marketing website loads its own copy of the analytics on its home, product, pricing, comparison, migration and blog pages (`ANALYTICS_PATHS` in its `data/site.js`), under the same consent cookie, and `ANALYTICS_RULE` in `lib/legal.js` names those pages for the privacy policy: change them together.

Expand All @@ -203,7 +203,7 @@ FlockStack Cloud counts how people use its own pages with PostHog: one project o

The choice is the first-party cookie `fs_consent` (`accepted` or `declined`, 12 months, `SameSite=Lax`, `Secure`). Before it, nothing is sent and no `ph_` cookie or local storage is written; posthog-js is not even downloaded. Declining deletes them. `fs_goal` is a ten-minute cookie holding the name of a step only the server sees (a new account, a first newsletter, a plan started: `siteExtensions.sendGoalOnNextPage`); the next page reports it and removes it. It is set only while analytics is on. FlockStack never calls PostHog from the server.

**What it collects.** Page views (including route changes), autocapture, device, browser and approximate location from PostHog; for a signed-in person, their user ID as the distinct ID with `email` and `plan` as the person's properties (`person_profiles: "identified_only"`); and four events, `signup_completed`, `newsletter_created`, `campaign_sent` (with `when`: `now` or `scheduled`) and `plan_started`, reported through `reportGoal` (`lib/goals.js`) from client code or the `fs_goal` cookie. Steps in shared markup with a `data-goal` attribute are kept on the autocaptured clicks. Every event goes through `sanitizeEvent` first: query strings and hashes are cut from addresses (a contacts search can hold an email address), and on the dashboard no element text, page title or label leaves, because a link there can be a subscriber's address or an issue's subject. `tests/analytics-privacy.test.mjs` fixes the property names. Session recording, surveys, heatmaps, dead clicks, error tracking and request timing are off.
**What it collects.** Page views (including route changes), autocapture, device, browser and approximate location from PostHog; for a signed-in person, their user ID as the distinct ID with `email` and `plan` as the person's properties (`person_profiles: "identified_only"`); and five events, `signup_started` (with `cta`, one of the fixed names in `SIGNUP_SOURCES`), `signup_completed`, `newsletter_created`, `campaign_sent` (with `when`: `now` or `scheduled`) and `plan_started`, reported through `reportGoal` (`lib/goals.js`) from client code or the `fs_goal` cookie. Steps in shared markup with a `data-goal` attribute are kept on the autocaptured clicks. Every event goes through `sanitizeEvent` first: query strings and hashes are cut from addresses (a contacts search can hold an email address), and on the dashboard no element text, page title or label leaves, because a link there can be a subscriber's address or an issue's subject. `tests/analytics-privacy.test.mjs` fixes the property names. Session recording, surveys, heatmaps, dead clicks, error tracking and request timing are off.

**Headers.** `next.config.mjs` sets a Content-Security-Policy without `script-src`, `connect-src` or `img-src`, so PostHog's host needs no entry and is allowed on no page in particular. If those directives are ever added, allow `https://us.i.posthog.com` and `https://us-assets.i.posthog.com` only for the routes that carry the analytics.

Expand Down
6 changes: 4 additions & 2 deletions app/(site)/privacy/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -449,10 +449,12 @@ export default function PrivacyPage() {
</li>
</ul>
<p>
<Strong>Analytics.</Strong> FlockStack&apos;s own pages load PostHog, which sets its own cookies or local storage (their names start with{" "}
<Strong>Analytics.</Strong> FlockStack&apos;s own pages load PostHog, which sets its own cookies, local storage or session storage (their names start with{" "}
<code>ph_</code>) to tell visits apart. {ANALYTICS_RULE} Those are not on this list because they are not needed for the service. When you
sign up, set up your first newsletter or start a plan, FlockStack also sets <code>fs_goal</code> for ten minutes, which the next page reads
once to count that step and removes. It is set only while analytics is on. Change your choice any time with <Strong>Cookie settings</Strong>{" "}
once to count that step and removes. It is set only while analytics is on. When you press a sign-up button on our website while analytics is on,
the button&apos;s name (such as <code>hero-primary</code>) is added to the address of the sign-in page and kept in PostHog&apos;s session storage until
the browser session ends or you create the account, so we can see which page brought sign-ups. It is never your email address or anything you typed. Change your choice any time with <Strong>Cookie settings</Strong>{" "}
in the footer of the home page, the product, pricing, comparison, migration and blog pages, the docs and the legal pages, and on the sign-in page. Declining deletes the PostHog cookies and local
storage.
</p>
Expand Down
2 changes: 1 addition & 1 deletion app/signin/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default async function SigninPage({ searchParams }) {
<SupportChat />
</Suspense>
)}
{SiteAnalytics && <SiteAnalytics />}
{SiteAnalytics && <SiteAnalytics signupSource={params.cta} />}
</LayoutSplit>
);
}
Expand Down
5 changes: 3 additions & 2 deletions components/marketing/ProviderAnalytics.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useIsClient } from "@/lib/use-is-client";
// and passes what it read from the request). The browser applies the rules again: a privacy signal means nothing is
// loaded, sent or stored, a visitor who must consent sees the banner first, and posthog-js is imported only once
// they may be tracked. It renders the marker below for the server-rendered page and the banner once it is needed.
export default function ProviderAnalytics({ token, host, replay, region, requiresConsent, consent: serverConsent, identity, privacyHref = null }) {
export default function ProviderAnalytics({ token, host, replay, region, requiresConsent, consent: serverConsent, identity, signupSource = null, privacyHref = null }) {
const isClient = useIsClient();
const [choice, setChoice] = useState(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
Expand All @@ -35,6 +35,7 @@ export default function ProviderAnalytics({ token, host, replay, region, require
let isCurrent = true;
const person = userId ? { id: userId, email: userEmail, plan: userPlan } : null;
analytics.start({ token, host, replay, region, identity: person }).then(() => {
if (isCurrent && signupSource) analytics.track("signup_started", { cta: signupSource });
const goal = isCurrent ? takeGoalCookie() : null;
if (goal) analytics.track(goal);
});
Expand All @@ -43,7 +44,7 @@ export default function ProviderAnalytics({ token, host, replay, region, require
isCurrent = false;
analytics.pause();
};
}, [isClient, status, token, host, replay, region, userId, userEmail, userPlan]);
}, [isClient, status, token, host, replay, region, userId, userEmail, userPlan, signupSource]);

useEffect(() => {
const signOut = () => analytics.reset();
Expand Down
12 changes: 7 additions & 5 deletions components/marketing/SiteAnalytics.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@ import { isCloud } from "@/lib/config";
import { isLegalPublished } from "@/lib/legal";
import { getAccountPlan } from "@/lib/plans";
import { readAnalyticsEnv } from "@/lib/marketing/analytics-config";
import { toSignupSource } from "@/lib/marketing/analytics-privacy";
import { readAnalyticsRequest } from "@/lib/marketing/analytics-request";
import { getRegion } from "@/lib/region";

// FlockStack Cloud's product analytics (PostHog), as SiteAnalytics in components/SiteExtensions.jsx. It goes in the
// layouts of FlockStack's own pages only: the docs, legal pages, sign-in and the signed-in dashboard. Never in the root
// layout, and never on a newsletter's pages, the pages readers use, or the admin (tests/analytics-routes.test.mjs lists
// where it is). The marketing website, a separate Worker, loads its own copy on its home, product, pricing, comparison, migration
// and blog pages. user and account identify a signed-in person.
// and blog pages. user and account identify a signed-in person; signupSource is the ?cta= of the sign-in address (the website's sign-up button), kept only if toSignupSource knows it.
// No POSTHOG_KEY renders nothing, without waiting for the request.
export default function SiteAnalytics({ user = null, account = null }) {
export default function SiteAnalytics({ user = null, account = null, signupSource = null }) {
if (!isCloud() || !readAnalyticsEnv()) return null;
return <AnalyticsForRequest user={user} account={account} />;
return <AnalyticsForRequest user={user} account={account} signupSource={signupSource} />;
}

async function AnalyticsForRequest({ user, account }) {
async function AnalyticsForRequest({ user, account, signupSource }) {
const request = await readAnalyticsRequest();
if (!request) return null;
const { token, host, replay, requiresConsent, consent } = request;
const source = toSignupSource(signupSource);
const identity = user && account ? { id: String(user.id), email: user.email, plan: getAccountPlan(account).id } : null;
return <ProviderAnalytics token={token} host={host} replay={replay} region={getRegion()} requiresConsent={requiresConsent} consent={consent} identity={identity} privacyHref={isLegalPublished() ? "/privacy" : null} />;
return <ProviderAnalytics token={token} host={host} replay={replay} region={getRegion()} requiresConsent={requiresConsent} consent={consent} identity={identity} {...(source && { signupSource: source })} privacyHref={isLegalPublished() ? "/privacy" : null} />;
}
16 changes: 15 additions & 1 deletion lib/legal.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/marketing/analytics-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function createAnalytics({ load = () => import("posthog-js").then((module
const event = toEvent(goal, props);
if (!client || !isWanted || !event) return false;
client.capture(event.name, event.props);
if (event.name === "signup_started" && event.props.cta) client.register_for_session({ signup_cta: event.props.cta });
return true;
}

Expand Down
31 changes: 31 additions & 0 deletions lib/marketing/analytics-privacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,34 @@
// listed, and everything PostHog collects by itself (page views, clicks) is scrubbed of text and query strings before it
// leaves the browser (sanitizeEvent). tests/analytics-privacy.test.mjs checks the names.

// The buttons of the marketing website that lead to sign-in, by the fixed name it gives them (data-cta, and ?cta= on the
// sign-in address once a visitor who may be tracked presses one). The same list as SIGNUP_SOURCES in the website's
// lib/signup-sources.js (docs/reference/signup-sources.js there): change them together. Anything else is not a source.
export const SIGNUP_SOURCES = Object.freeze([
"header-signup",
"hero-primary",
"start-section",
"pricing-summary",
"plan-free",
"plan-starter",
"plan-growth",
"plan-pro",
"plan-scale",
"plan-max",
"sticky-compare",
"post-next-signup",
"footer-signup",
]);

// The source in a ?cta= value, or null for anything not on the list.
export function toSignupSource(value) {
return typeof value === "string" && SIGNUP_SOURCES.includes(value) ? value : null;
}

// The explicit events, each with the properties it may carry and the values they may take. track() drops the rest.
export const EVENTS = Object.freeze({
// A visitor who pressed a sign-up button on the website reached the sign-in page.
signup_started: { cta: SIGNUP_SOURCES },
signup_completed: {},
newsletter_created: {},
campaign_sent: { when: ["now", "scheduled"] },
Expand All @@ -20,8 +46,13 @@ export const PERSON_PROPERTIES = Object.freeze(["email", "plan"]);
// On every event: which deployment the visitor was on.
export const SUPER_PROPERTIES = Object.freeze(["region"]);

// On the events of one browser session only, from the sign-in page on (posthog-js register_for_session): the sign-up button
// that brought the visitor, so that signup_completed says where the account came from.
export const SESSION_PROPERTIES = Object.freeze(["signup_cta"]);

// The steps the server reports for the browser's next page (siteExtensions.sendGoalOnNextPage), as events.
export const GOAL_EVENTS = Object.freeze({
signup_started: "signup_started",
signup_completed: "signup_completed",
onboarding_completed: "newsletter_created",
newsletter_created: "newsletter_created",
Expand Down
Loading
Loading