The first month after launch is a bad time to learn that the dashboard counts page views and nothing else, and that you have no idea where users drop off between sign-up and first payment. The fix is 4 funnel events: named once, sent from the server where money moves, tied to each visitor’s consent choice, and proven to fire.

No idea where users drop off: what product analytics is, and the four events it starts with

Product analytics records what identified users do as named events with properties, so the steps line up into a funnel. A small SaaS starts with 4 events: signed up, activated, the core action, and payment succeeded. Page-view analytics cannot show where users drop off, because activation and payment are not pages.

Mixpanel’s docs describe the same three parts in plain terms: “Events are actions that happen in your product”, users are the people who use it, and properties are the attributes of both. A page-view counter has only the first part, and only for one kind of event. It can tell you how many people loaded /pricing, but it cannot tell you how many of last week’s sign-ups created a project, because “created a project” is a row in your database, not a URL.

In the stack, product analytics lives in two places. A browser SDK records interface events, such as a button clicked or a page reached. A server SDK, or a plain HTTP call, sits inside the handlers where the truth is decided: the account row is written, the payment is confirmed. It is one of the checks that belong under logging and monitoring, and the one that answers a product question instead of an outage question.

This is the tracking plan I’d start with for a small SaaS. The names follow one rule, and the core action row is a placeholder you rename for your product.

EventFires whenSent fromPropertiesThe question it answers
signed_upThe account row is createdServeraccount_id, signup method, plan, referrer classHow many visitors become users?
activatedThe user reaches the first moment of value, defined in one sentence for your product, for example “created first project”Server or clientaccount_id, days since sign-upHow many new users get something out of the app?
report_generated (rename to your core action)The user does the thing a retained user repeatsServer or clientaccount_id, the action’s typeDo activated users come back for it?
payment_succeededThe payment provider confirms the payment, never when the success page loadsServeraccount_id, plan, amount bucket, first payment or renewalWho pays, and on which plan?

Add identify on sign-up and on login. In PostHog’s case, calling it when a user logs in or creates an account “merges the anonymous person into the identified person”, so the visits before sign-up join the account’s history; PostHog’s identify docs show the call. Four events is where to start, not a ceiling: add a fifth when you have a question none of the four answers.

What goes wrong without it

Six failure modes are the ones I’d look for first, and each leaves a different mark on the chart. The middle column is my reading of what each looks like from the dashboard.

What is wrongHow it shows in the chartThe fix section
Page views onlyTraffic is visible, and no funnel can be built past the landing pageWrite the tracking plan first
One event under three names (signup, sign_up, user_signed_up), added in three separate coding sessionsA funnel built on one name misses the users counted under the other twoWrite the tracking plan first
The payment event sent from the browser’s success pageRevenue in analytics falls short of the payment provider’s totalSend money events from the server
An event that fires more than once per action, from a component effect that runs on every renderA step shows more events than people, and conversion looks better than it isHow to verify analytics events fire correctly, the second check
Events sent before consent, or personal data in properties, such as an email address as a property valueThe chart looks normal; the problem sits in the raw eventsConsent-aware collection
Nobody ever checkedThe first funnel anyone builds is wrong, and it gets believedHow to verify analytics events fire correctly

The third row has a named cause. Ad blockers, in PostHog’s words, “maintain lists of known analytics domains and block requests to them”, so any event the browser sends to such a domain never arrives from visitors who run one.

Picture a founder whose app sends its payment event to the analytics tool from the browser, when the success page loads. Customers whose ad blockers block requests to known analytics domains never send it, so the funnel shows fewer payments than the payment provider does, and the gap gets read as customers dropping off at checkout. The point is plain: the team needs to see where customers progress or encounter friction.

Analytics shows where users stop, not whether the app was down when they did. That signal comes from outside the app, as in what is uptime monitoring, and what it catches.

Core product analytics is set up in 5 moves: write the tracking plan in one file, send sign-up and payment events from the server, make browser and server capture follow the visitor’s consent choice, pick a tool with a server-side option and a consent switch, then build the funnel.

The five sections below are in the order the work is done.

Write the tracking plan first: one name per event, in one file

A tracking plan is the table from the first section, kept in the repository next to the code that sends the events. Pick one naming rule once and never mix it; mine is object then past-tense verb, in snake case (signed_up, payment_succeeded). One module exports the event names and the property types, and every call site imports from it. When an AI agent adds tracking later, the type checker rejects a fourth spelling of sign-up passed to track.

// analytics/events.ts: the only file that spells event names
type Plan = 'free' | 'starter' | 'pro';

export type TrackingPlan = {
  signed_up: { account_id: string; signup_method: 'email' | 'google'; plan: Plan; referrer_class: string };
  activated: { account_id: string; days_since_signup: number };
  report_generated: { account_id: string; report_type: 'summary' | 'export' };
  payment_succeeded: { account_id: string; plan: Plan; amount_bucket: 'small' | 'medium' | 'large'; first_payment: boolean };
};

export type EventName = keyof TrackingPlan;

export function track<E extends EventName>(
  name: E,
  props: TrackingPlan[E],
  send: (name: string, props: Record<string, unknown>) => void,
) {
  send(name, props);
}

The send argument is whatever your tool’s SDK exposes, so the module stays the same if you switch tools. My property rules are short: ids, never emails; buckets, not exact amounts, where exactness adds nothing; no free text typed by users; and the account or workspace id on every event, so a B2B funnel can be read per customer.

If GA4 is your tool, use its names where they fit. GA4’s recommended events include sign_up, which “indicates that a user has signed up for an account”, and purchase, which “signifies when one or more items is purchased by a user”. The tracking plan file then maps your names to those.

Send money events from the server

The browser is an unreliable witness for anything that touches revenue. Ad blockers drop requests to analytics domains (the third row of the failure table), and users close the tab before the success page finishes loading. So signed_up is sent from the code that creates the account row, and payment_succeeded from inside the verified payment webhook handler.

Webhooks bring their own trap. Stripe’s webhook guide warns that an endpoint “might occasionally receive the same event more than once”, so send the analytics event from the branch of the handler that has already skipped duplicates, never before that check. The table, the unique key and the transaction that make that check safe are covered in how to make a webhook handler idempotent.

Server events must land on the same person as the browser events. PostHog’s backend SDKs “have no concept of an anonymous session”, so you pass the same distinct_id the frontend used after identify. On GA4, server events go through the GA4 Measurement Protocol, which “is intended to supplement, not replace, automatic data collection methods like gtag, Tag Manager, and Google Analytics for Firebase”. Its client_id “should match the ID generated by the Google Analytics tag on your website”, so save the browser’s client id with the account at sign-up, or pass it along with the checkout, before the webhook needs it.

For the browser events you keep, a first-party reverse proxy routes them through your own domain. PostHog’s reverse proxy page says you don’t need one to start, and “We recommend setting one up before going to production for more reliable data capture.” At this size I treat it as optional, and a proxy changes the route, not the rules: it sits behind the same consent choice as everything else.

The signup and revenue numbers on an operations screen should come from the database and the payment provider, not from analytics. Those numbers belong on an ops dashboard, a separate piece of work.

Whether analytics needs consent for your audience, the rule behind it, the banner, script blocking and consent mode setup belong to cookie consent requirements. This section covers only what the code does once you know the answer.

PostHog’s data collection controls recommend that you “load PostHog opted out by default, then opt the user in once they consent”: set opt_out_capturing_by_default: true, call opt_in_capturing() on consent, and call opt_out_capturing() when the user “withdraws or denies consent”. The same page warns against gating the snippet itself behind the banner, because an SDK that never loads “can’t respect a consent choice made later in the session”.

Opting out of capture is not opting out of storage. PostHog’s opt-out status “is persisted automatically using: local storage or cookies for browsers”. A separate setting in PostHog’s JavaScript configuration options, opt_out_persistence_by_default, defaults to false and “Determines if users should be opted out of browser data storage by this PostHog instance by default”. The other route is cookieless_mode: "on_reject": PostHog “still loads and counts users with a privacy-preserving hash, and only starts storing cookies once the user opts in”. It works only after you enable “Cookieless server hash mode” under Project Settings, then Web analytics.

On Google’s side, Google’s consent mode overview describes two forms. In basic mode, when the user does not consent, “no data is transferred to Google at all”; in advanced mode the tags load, and while consent is denied they “send measurements without cookies”. Which mode you pick decides what the refused-consent check further down expects.

The opt-out switch lives in the browser: PostHog says “Opting out on a PostHog client will prevent all data from being captured and sent to PostHog”, while its backend SDKs capture with the distinct_id your server code passes them. So store the visitor’s choice with the account at sign-up, have the server read it before every capture, and skip or strip the server event when the choice was refused. On GA4 in basic mode the tag never fires for a refused visitor, so there is no client id for the server to attach an event to. Whether a server-sent event needs the same choice, and where the analytics vendor sits in your data protection picture, is the lawful-basis question covered in GDPR for a small SaaS.

Analytics for developers: which tool, and what “developer analytics” means

Analytics for developers means two different things: metrics about an engineering team’s delivery, and analytics a developer installs in a product. For the second, the choice is between an event-based platform such as the open-source PostHog, a marketing suite such as Google Analytics 4, and events kept in your own database.

The first meaning is a different product: tools such as DX and LinearB measure how a team ships code, and they say nothing about your users. This page is about the second. Google uses the phrase for its own docs too: Google’s Analytics developer hub has an “Analytics for developers” path, for developers who want to “tag a website or app, set up events or ecommerce”.

The table is my grouping of the three tool types.

Tool typeExamplesBuilt aroundFits when
Event-based product analytics platformPostHog, Mixpanel, AmplitudePeople, the events they send, and funnels across those eventsYou want funnels and retention per user without building them
Marketing analytics suiteGoogle Analytics 4Events, read mostly as sessions, traffic sources and campaignsMost of your questions are about where visitors come from
Warehouse-firstEvents written to your own databaseTables you own and query yourselfYou want the most control and accept the most work

The table has no license, server SDK or consent column, because this page sources those facts only from PostHog’s and Google’s docs, and the other cells would read “not stated”. The PostHog open source license is set out in its GitHub README: the repository “is available under the MIT expat license, except for the ee directory”, which has its own license if applicable, and a separate posthog-foss repository “is purged of all proprietary code and features”. The same README’s SDK table lists Python, Node, PHP and Ruby under Backend, and it points to docs and guides for Go and .NET/C# beyond those. GA4 takes server events through the Measurement Protocol, with the client id condition from the server section.

My choice rule for a small SaaS has three parts: a server-side option in your backend’s language, a consent switch in the browser SDK, and funnels on the free tier at your volume. Any of the three types can meet it; I name no winner. Self-hosting and price are in the questions at the end. Error and monitoring tools are a separate choice, compared in Sentry vs Datadog.

Read the funnel: where users drop off, and how to calculate it

A funnel is the four events in order, inside a time window. My starting window is 14 days from signed_up. Funnel drop-off at a step is the share of people who reached the previous step and did not reach this one: one minus (users at this step divided by users at the previous step).

Here is my worked example, with round illustrative numbers I picked to show the arithmetic only.

StepUsersConversion from previous stepDrop-off
signed_up1,000not applicablenot applicable
activated40040%60%
report_generated20050%50%
payment_succeeded5025%75%

The last step has the highest drop-off rate, but in my example the step into activation loses the most people: 600 of the 1,000 never activate. Read the biggest absolute loss first. Then split that step by one property, such as signup method, plan or device, before guessing at a cause, and open five real users’ event lists before you change anything. With small numbers, percentages mislead: under about 100 users at a step, I write counts, not percentages.

How to verify analytics events fire correctly

Analytics events are verified by running the core journeys with a fresh test account and checking 7 things: each event arrives once, with the planned name and properties; identities join; a refused-consent run sends only what the chosen mode allows; the payment event survives an ad blocker; a repeated webhook counts once; and the funnel shows the account.

The checks below come from the vendors’ documentation, applied to the tracking plan above. Run them on a deployed preview or a production build, not the dev server: React’s Strict Mode re-runs effects “an extra time” in development only, so a dev server can show a duplicate that production does not have.

  1. 01 Open the tool's live event view, then sign up with a fresh test account and complete activation, the core action and a test-mode payment. On PostHog the view is the activity tab; on GA4 it is DebugView, with debug mode on, and server-sent test events show there when they carry debug_mode and a positive engagement_time_msec. Evidence: the event list for the test user.
  2. 02 Each event arrives once, with the exact name from the tracking plan file. Evidence: the event count per name for the test user.
  3. 03 Each event carries every property in the plan, with the right type, and no email address or free text. Evidence: the expanded event's properties.
  4. 04 Identities join. On PostHog, look the test user up by the anonymous id and by the account id: both show the full history, from before and after sign-up. On GA4, the server-sent events carry the same client id as the browser events. Evidence: the person's event history.
  5. 05 Repeat the journey in a fresh browser with consent refused. What arrives matches your setup: nothing from the browser for PostHog opted out by default or Google's basic mode, a hashed count for PostHog's on_reject mode, measurements without cookies for Google's advanced mode, and from the server only what your stored-choice rule allows. The storage panel holds no analytics identifier where the setup is meant to store none. Then accept, then withdraw, then reload in a new session: capture starts, stops, and the last choice holds. Evidence: the storage panel, the consent state and the event list for that session.
  6. 06 Repeat the payment with an ad blocker on: payment_succeeded still arrives, because the server sent it. Then have the payment provider deliver the same event a second time, as a fresh delivery, and confirm the count stays at one. Evidence: the event count for that payment.
  7. 07 Build the four-step funnel and confirm the test account appears at every step, waiting out the tool's processing time first. Evidence: the funnel, with the date you ran it.

Some of those checks need a vendor detail. For check 1, PostHog’s activity tab is, in its docs’ words, “great for checking events are being captured”; GA4 DebugView sits under Admin, then Data display, then DebugView. For server events, Google’s Measurement Protocol docs ask for "debug_mode": true and "engagement_time_msec" set to a positive number in the test event’s params.

For check 5 on GA4, DebugView is the wrong place to look: Google’s help page says events are not visible there “if you’ve implemented consent mode and users have not given consent for Analytics cookies”. Read that run in Tag Assistant, which Google says “helps check the default consent state, consent updates after user interaction, tag behavior based on consent”, and in the browser’s Network tab. On PostHog, on_reject mode stores no PostHog data until the user opts in; opted out by default stores no analytics identifier only when opt_out_persistence_by_default is also set, and even then the opt-out choice itself shows in the storage panel, because PostHog stores consent decisions “separately from analytics persistence”.

For check 6 on Stripe, the fresh delivery is Resend in the Dashboard, which works “for up to 15 days after the event creation”, or stripe events resend <event_id> --webhook-endpoint=<endpoint_id> against the registered endpoint, which works for up to 30 days. Never re-post a saved request: Stripe’s libraries have “a default tolerance of 5 minutes” on the signature timestamp, and a count that stays at one because the handler refused the request tests nothing.

For check 7 on GA4, Google says “Data processing can take 24-48 hours”, so read a funnel built in Explore the next day or later. Keep the date of each run, and rerun all seven after any change of tool, SDK version or consent banner.

In the Production Hardening Sprint, we verify deliverable 8.6 this way: “Run core journeys and verify event names, properties, and consent behavior.” For deliverable 12.4, the verify line reads: “Test consent, refusal, withdrawal, and persistence; verify optional scripts respect those choices.”

Where the sprint does this

Deliverable 8.6, Core product analytics, is where we “instrument signup, activation, core actions, and payment-funnel events with consent-aware collection”. The consent controls themselves are deliverable 12.4: we “implement consent controls for the application’s audience and tracking configuration, including EU visitors where relevant”. Deliverable 8.8 is to “create one view of uptime, error rate, latency, signups, and revenue for the application’s relevant services”. All of it lands in the production readiness report, deliverable 13.1, verified this way: “Account for all 123 IDs; keep failures visible until resolved and explain genuine non-applicable items.” We implement and document the technical data-handling controls; legal advice and certification are separate services. Hosting, paid tools, and API usage remain in your accounts. Each of these deliverables, with its verify line, is in the published scope.

Common questions about product analytics

Can I self-host PostHog?

Yes. PostHog offers “a free Docker Compose deployment under an MIT license”, but self-hosted deployments “are officially unsupported”, the server needs “something equivalent to a Hetzner VM with 4 vCPU, 16GB RAM, and more than 30GB storage”, and “All paid-plan features are Cloud-only.” Its README adds that open-source deployments “should scale to approximately 100k events per month” and that PostHog does not provide customer support or guarantees for them. My reading: for most small apps, the hosted free tier is less work. The details are in PostHog’s self-hosting docs.

Is PostHog free to use?

Yes, up to a monthly allowance. On 2026-10-03, PostHog’s pricing page listed 1M analytics events in its free allowance and said “Your free allowance renews every month.” The self-hosted software is free too, but you run or pay for the server yourself.

How does PostHog compare to Mixpanel?

On paper they are close: both are built on events, users and properties, as Mixpanel’s docs set out. This is documented analysis, not a hands-on test. PostHog’s code is MIT-licensed except its ee directory, and it can be self-hosted. Self-hosting is not stated in Mixpanel’s docs (checked 2026-10-03). On the same date, Mixpanel’s pricing page listed its Free plan at “Up to 1M events / month”, the same size as PostHog’s free analytics allowance. I name no winner; the fair test is your own sign-up flow sent to both.

What is a 2% conversion rate?

It means two in every hundred people who started a step finished it. Whether that is good depends entirely on the step: two in a hundred from visit to sign-up and two in a hundred from trial to paid are different stories. I compare a step with its own previous month, not with an industry benchmark, because another product’s numbers say little about your step.