Churn Prediction Engineering: The Product Telemetry Signals That Actually Forecast Cancellation

#churn prediction SaaS
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most SaaS teams discover they have a churn problem when they open the billing dashboard and see a cancellation spike. By then, the signal arrived two or three weeks ago — and nobody was watching it. The customer had already made their decision. They just had not clicked the button yet.

The reason traditional churn models fail is that they watch the wrong data. Billing events, support ticket volume, and renewal date proximity are lagging indicators. They confirm that a cancellation is happening; they do not predict that one is coming. The signals that actually forecast cancellation are buried in your product telemetry, and most teams either are not collecting them or are not connecting them to customer health in any structured way.

This post is the engineering playbook for fixing that. It covers which telemetry events matter, the event schema that stays stable as your product evolves, a warehouse-grade storage pattern that does not require a Snowflake contract, and the dashboard shape that customer success teams will actually use.

Why Billing and Support Data Arrive Too Late

A cancellation has two phases: the decision and the action. Most churn analytics tools measure the action — the moment a subscription moves to cancelled in Stripe. The decision happened earlier, often much earlier, and it was made by a user who stopped seeing value long before they called support or skipped a renewal conversation.

Support tickets are equally unreliable as early-warning signals. Customers who open tickets are still trying to make the product work. The ones who have already given up never submit another ticket — they just quietly stop logging in. Silence is not a signal that traditional support tooling captures.

Product telemetry is different. It measures what users are actually doing, session by session, feature by feature. When a customer is disengaging, the behavioral pattern appears in that data well before any billing or support event surfaces.

The Five Telemetry Signals That Actually Predict Churn

Churn prediction SaaS models built on product data converge on five signal types. Not all of them are obvious.

Feature adoption decay is the clearest leading indicator. A customer who used your reporting module every week and has not opened it in three weeks is not busy — they found a workaround, gave up on the value, or started evaluating alternatives. The key is measuring adoption per feature over a trailing window, not just global session frequency, because customers often maintain overall login activity while quietly abandoning the features that made them sign up.

Session depth collapse is the second signal. Session depth measures how far into the product a user travels per visit — how many distinct surfaces they touch, how many actions they complete. A customer whose average session depth drops from six interactions to two is telling you that they are logging in to do one specific thing and leaving. That is an account that has narrowed its perceived value. It rarely recovers without intervention.

Invitation dead-ends matter more than most teams realize. A customer who invited three colleagues and none of them activated their accounts has a broken adoption funnel. That account is structurally at risk because the product never became a team habit. Measuring invitation-to-activation rate per account, rather than globally, surfaces accounts where the social adoption loop failed.

Admin-user offboarding is a blunt signal but a reliable one. When the person who owns the subscription — the admin, the decision-maker — stops logging in, cancellation follows within weeks at a rate that should alarm any customer success team. This is different from general user inactivity. Admin offboarding means the internal champion left or deprioritized the tool.

Integration disconnection precedes roughly nine out of ten voluntary downgrades and cancellations in B2B SaaS products with webhook or API integrations. When a customer disconnects an integration — whether it is your Slack app, a Zapier trigger, or your own webhook configuration — they are reducing their exposure to your product's value surface. Disconnect events are almost never instrumented as churn signals. They should be the first thing you add.

The Event Schema That Stays Stable

The failure mode of most product analytics implementations is an event taxonomy that starts clean and collapses into chaos within six months. Someone names a button-click event button_clicked, another engineer names a similar one clicked_cta, a third adds user_clicked_upgrade_button, and within a year you have three hundred distinct event names with no consistent semantics, no way to aggregate across them, and a data warehouse that is expensive to query and impossible to trust.

A stable schema starts with three things: a consistent entity model, a verb-noun naming convention, and a versioned property envelope.

The entity model for churn prediction needs at least four entities: account (the billing unit), user (the human actor), feature (the product surface), and session (the temporal container). Every event should carry identifiers for the relevant entities. An event with no account_id is useless for account-level health scoring.

The verb-noun convention keeps event names queryable across a growing taxonomy:

feature.viewed
feature.activated
feature.abandoned
session.started
session.depth.recorded
integration.connected
integration.disconnected
invitation.sent
invitation.accepted
invitation.expired

The property envelope wraps every event in a consistent structure regardless of what the event itself carries:

{
  "event": "feature.abandoned",
  "version": "1",
  "timestamp": "2026-05-23T11:04:00Z",
  "account_id": "acc_7x9p2",
  "user_id": "usr_m3k1",
  "session_id": "ses_9qr4",
  "feature_id": "reporting.export",
  "properties": {
    "time_in_feature_ms": 4200,
    "last_successful_use_days_ago": 18
  }
}

The version field is not decoration. When you need to change a property name or add a required field, versioning lets you write queries that handle both schema shapes without breaking historical analysis.

Instrumenting This in a Next.js and Symfony Stack

On the Next.js side, the instrumentation layer belongs in a thin client-side analytics module that wraps your event calls and enforces the envelope structure:

// lib/analytics.ts
interface EventPayload {
  event: string;
  version?: string;
  feature_id?: string;
  properties?: Record<string, unknown>;
}

export function track(payload: EventPayload): void {
  const envelope = {
    ...payload,
    version: payload.version ?? '1',
    timestamp: new Date().toISOString(),
    session_id: getSessionId(),
    account_id: getAccountId(),
    user_id: getUserId(),
  };

  navigator.sendBeacon('/api/events', JSON.stringify(envelope));
}

Using sendBeacon rather than fetch matters for session-end events: the browser will queue the send even if the user navigates away before the request completes, which is exactly when you most need session depth data to arrive intact.

The /api/events route validates the envelope and writes to a buffer — a Redis list works well here — that a Symfony worker drains into your storage layer asynchronously.

On the Symfony side, a dedicated Messenger consumer handles the drain:

// src/MessageHandler/TrackEventHandler.php
class TrackEventHandler implements MessageHandlerInterface
{
    public function __construct(
        private EventRepository $events,
        private AccountHealthUpdater $healthUpdater
    ) {}

    public function __invoke(TrackEventMessage $message): void
    {
        $event = $this->events->persist($message->envelope);

        if ($this->isChurnSignal($event)) {
            $this->healthUpdater->recalculate($event->accountId);
        }
    }

    private function isChurnSignal(PersistedEvent $event): bool
    {
        return in_array($event->name, [
            'feature.abandoned',
            'integration.disconnected',
            'invitation.expired',
        ]);
    }
}

The AccountHealthUpdater does not run complex ML — at this stage it does not need to. A weighted score across the five signal types, recalculated on each triggering event and on a nightly batch job, is sufficient to surface at-risk accounts without a data science team.

Warehouse-Grade Storage on a Startup Budget

The temptation for growing teams is to store events directly in the application PostgreSQL database. This works until it stops working — typically around the 50 million event mark, when query performance degrades and the engineering team spends a weekend adding partitioning that should have been there from the start.

The pattern that scales without a large infrastructure investment is append-only event storage in partitioned PostgreSQL tables, with account-level aggregates maintained separately.

Partition by month on the timestamp column. This keeps individual partitions small, makes time-range queries fast, and lets you drop old partitions without a full-table operation when retention policy requires it:

CREATE TABLE product_events (
  id          BIGSERIAL,
  event       TEXT        NOT NULL,
  version     TEXT        NOT NULL DEFAULT '1',
  timestamp   TIMESTAMPTZ NOT NULL,
  account_id  TEXT        NOT NULL,
  user_id     TEXT,
  session_id  TEXT,
  feature_id  TEXT,
  properties  JSONB
) PARTITION BY RANGE (timestamp);

CREATE INDEX ON product_events (account_id, timestamp DESC);
CREATE INDEX ON product_events (event, timestamp DESC);

The aggregates table is what your application queries for health scoring — not the raw events table:

CREATE TABLE account_feature_usage (
  account_id     TEXT        NOT NULL,
  feature_id     TEXT        NOT NULL,
  last_used_at   TIMESTAMPTZ,
  uses_last_7d   INT DEFAULT 0,
  uses_last_30d  INT DEFAULT 0,
  decay_score    NUMERIC(4,2),
  updated_at     TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (account_id, feature_id)
);

The decay_score column encodes feature adoption decay as a number between 0 and 1. A nightly job recomputes it: uses_last_7d / nullif(uses_last_30d, 0). An account where that ratio drops below 0.15 for a core feature is a churn risk.

The Dashboard Customer Success Actually Uses

The last piece is output. A churn prediction system that produces a score no one reads is infrastructure expense with no business return.

Customer success teams do not want a probability score. They want a prioritized list of accounts to call, with enough context to make the call feel informed rather than awkward. The dashboard that gets used has three columns per account row: the account name, the triggering signal (what changed), and the suggested action (what to say).

Triggering signals map directly to the event types: "No exports in 21 days" reads more actionably than "decay_score: 0.09." The suggested action does not need AI to generate — a lookup table keyed on signal type covers ninety percent of cases well enough to make the conversation useful.

If you want to reach this point for your own SaaS product, the path runs through instrumenting those five signal types first and asking what data you actually have before building the scoring layer. Most teams are surprised by how much the picture changes once integration disconnection events and admin-user inactivity appear alongside the global session metrics they were already watching.

If your stack is Next.js and Symfony and you want a second set of eyes on the event schema or the storage architecture before you commit to it, hello@wolf-tech.io is the right place to start. We work with SaaS teams at exactly this inflection point — when the data question becomes a product retention question — at wolf-tech.io. Take a look at our services to see where we can help.