Product analytics answers "how is the product used". Billing answers "who pays". Kept apart, neither can answer the two questions that decide a SaaS company's month: which paying customers are drifting away, and did the release just cost us money? Put them together and both are a query.
This guide does that with PostHog and Stripe; the shape is the same for Mixpanel, Amplitude or a warehouse.
1. Group analytics by company
Analytics tools think in people. Revenue is per company. So the first move is to tell the analytics tool which company each person belongs to, at identify time, so every event carries it:
posthog.identify(user.id, { email: user.email });
posthog.group("company", account.id, { name: account.name, plan: account.plan, domain: account.domain });
In Mixpanel it is mixpanel.set_group("company", account.id); in Amplitude, a group type; in GA4, a user property (which is weaker, since it does not aggregate per company, but it is what there is).
Use the same account.id you use in billing (or store the Stripe customer id as a group property). This is the join key for everything below.
If you cannot change the tracking code today, the fallback is the domain of the person's email: lower(splitByChar('@', person.properties.email)[2]), with the public mailboxes excluded. It is right most of the time for B2B and wrong for anyone on Gmail; see identifying the customers behind an error for the rules.
2. Name the key event
Among all the events, one means the product did its job: checkout_completed for a store, report_sent for a reporting tool, deploy_succeeded for a deploy tool. Not page_view, not button_clicked. One per product. It is the event whose count going down is, by definition, the product working less.
If you do not have one, add it now; everything after a release is measured against it.
3. Pull each account's activity
Two series per account, per day: how many events, and how many distinct people. In PostHog's SQL (HogQL), by group:
SELECT toStartOfDay(timestamp) AS day,
$group_0 AS company,
count() AS events,
count(DISTINCT person_id) AS people
FROM events
WHERE timestamp >= now() - INTERVAL 60 DAY
AND $group_0 != ''
GROUP BY day, company
ORDER BY day
($group_0 is the first group type you defined; check the index in the project's settings.) Run it daily and keep the rows; sixty days is enough for the comparison below, a year is better.
For the key event, an hourly series, product-wide, is what the release check needs:
SELECT toStartOfHour(timestamp) AS hour, count() AS n
FROM events
WHERE event = 'checkout_completed' AND timestamp >= now() - INTERVAL 14 DAY
GROUP BY hour ORDER BY hour
4. Join to what they pay
The account table from billing: per company, the Stripe customer id, the active subscriptions normalised to a month, the renewal date. (The MRR arithmetic is in connecting Sentry errors to Stripe revenue; it is the same here.)
Join the activity rows to it on the company id. Now every row of usage has an MRR next to it, and the two questions can be asked.
5. The two comparisons
Usage this month against last, per paying account. For each company: events in the last 30 days against the 30 before. A drop of 30% or more, on an account that had meaningful activity to begin with (set a floor: at least 50 events last month, or whatever "using it" means for your product) is the churn warning. Order the list by MRR and it is the renewal call list.
-- per company: this month, last month, and the change
SELECT company,
sumIf(events, day >= today() - 30) AS this_month,
sumIf(events, day < today() - 30 AND day >= today() - 60) AS last_month,
round(100 * (this_month - last_month) / last_month) AS change_pct
FROM account_activity
GROUP BY company
HAVING last_month >= 50 AND change_pct <= -30
ORDER BY change_pct
The same query with the sign flipped (up 40% or more, more people on it) is the expansion list: accounts growing into a bigger plan, before they ask.
The key event after a release, against the same hours last week. For each hour since the release, compare the key event's count with the same hour of the same weekday the week before. Not with the hour before the release: a Tuesday afternoon does not look like a Tuesday night, and a quiet night will look like a broken checkout if the baseline is the afternoon. A drop of 30% or more, sustained over the hours since the release, on counts large enough to mean something, is the alarm. Read next to the error rate over the same hours, it is the whole story of a bad release.
Pitfalls
- Seasonality. Month against month breaks over holidays and at month-end for anything finance-shaped. Compare with the same period last year when you have it, or accept a noisy December.
- Seats, not events. An account that lost half its events but kept all its people may have changed how they use you, not whether. Watch both series.
- Bots and integrations. An API client that polls every minute is the most active "user" you have. Exclude service accounts and API keys from the activity series, or count only events from people.
- Plan limits. An account near its plan's limit uses less because it cannot use more. That is an expansion, not a churn risk; the plan property on the group tells them apart.
What Vesqo does with this
Vesqo runs both comparisons for you. From PostHog it reads the hourly count of all events and of the key event you name, and each customer's daily activity by the group type that stands for a company, or by email domain when there is none; from Stripe it reads what each account pays. After each release it sets the key event, hour by hour, against the same hour of the day the week before, next to the errors from Sentry; month against month it writes a usage-drop signal for a paying customer going quiet and an expansion-opportunity signal for one growing, each with the money next to it, in the morning brief. The PostHog integration page lists exactly what it reads, and the customer health use case shows the page it makes per customer.