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.
| Event | Fires when | Sent from | Properties | The question it answers |
|---|---|---|---|---|
signed_up | The account row is created | Server | account_id, signup method, plan, referrer class | How many visitors become users? |
activated | The user reaches the first moment of value, defined in one sentence for your product, for example “created first project” | Server or client | account_id, days since sign-up | How many new users get something out of the app? |
report_generated (rename to your core action) | The user does the thing a retained user repeats | Server or client | account_id, the action’s type | Do activated users come back for it? |
payment_succeeded | The payment provider confirms the payment, never when the success page loads | Server | account_id, plan, amount bucket, first payment or renewal | Who 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 wrong | How it shows in the chart | The fix section |
|---|---|---|
| Page views only | Traffic is visible, and no funnel can be built past the landing page | Write the tracking plan first |
One event under three names (signup, sign_up, user_signed_up), added in three separate coding sessions | A funnel built on one name misses the users counted under the other two | Write the tracking plan first |
| The payment event sent from the browser’s success page | Revenue in analytics falls short of the payment provider’s total | Send money events from the server |
| An event that fires more than once per action, from a component effect that runs on every render | A step shows more events than people, and conversion looks better than it is | How 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 value | The chart looks normal; the problem sits in the raw events | Consent-aware collection |
| Nobody ever checked | The first funnel anyone builds is wrong, and it gets believed | How 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.
How to do it: the tracking plan, the sending side, consent, the tool and the funnel
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.
Consent-aware collection: nothing optional fires before a choice
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 type | Examples | Built around | Fits when |
|---|---|---|---|
| Event-based product analytics platform | PostHog, Mixpanel, Amplitude | People, the events they send, and funnels across those events | You want funnels and retention per user without building them |
| Marketing analytics suite | Google Analytics 4 | Events, read mostly as sessions, traffic sources and campaigns | Most of your questions are about where visitors come from |
| Warehouse-first | Events written to your own database | Tables you own and query yourself | You 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.
| Step | Users | Conversion from previous step | Drop-off |
|---|---|---|---|
signed_up | 1,000 | not applicable | not applicable |
activated | 400 | 40% | 60% |
report_generated | 200 | 50% | 50% |
payment_succeeded | 50 | 25% | 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.
- 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.
- 02 Each event arrives once, with the exact name from the tracking plan file. Evidence: the event count per name for the test user.
- 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.
- 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.
- 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.
- 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.
- 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.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase