Virality analytics (K‑factor)
Motivation
A referral program lives or dies by one number: the viral coefficient, or K‑factor. If each user
brings more than one new user, growth compounds; below one, it decays. But a K‑factor computed from a
separate rollup table drifts from reality the first time a job is missed or a row is corrected. This
package computes every metric directly from the canonical tables — Redemption, Referral,
Invitation, InviteCode — so the numbers always reconcile with the raw rows.
Theory
Let, within the chosen tenant / campaign / time window:
= qualified referrals (status qualifiedorrewarded)= distinct referrers = invitations sent, = invitations accepted = codes issued, = redemptions
The metrics are:
Each ratio is 0.0 when its denominator is zero (no division‑by‑zero, no NaN), and every value is
rounded to 4 decimal places. A campaign with
move acceptance and conversion up and watch
Time‑to‑redeem percentiles
The time‑to‑redeem for a redemption is
seconds. For a percentile
nearest‑rank offset:
and fetches the single row at that offset — ordered in SQL, never loaded into PHP. ttr_p50 and
ttr_p90 are the median and 90th‑percentile latencies.
Design
MetricsService is a pure read model: it issues tenant‑scoped count/distinct queries against
the canonical tables and assembles the summary. The analytics event log (invite_analytics_events)
is the audit trail of funnel transitions; the metrics service is the reconciled read over the truth.
Data model / contract
MetricsService::summary(?int $campaignId = null, ?int $sinceDays = null) returns:
| Key | Type | Definition |
|---|---|---|
codes_issued |
int |
codes created in scope |
redemptions |
int |
redemptions in scope |
invites_sent |
int |
invitations with a sent_at |
invites_accepted |
int |
invitations with status accepted |
referrals_qualified |
int |
referrals qualified or rewarded |
distinct_referrers |
int |
distinct referrer_id |
k_factor |
float |
|
acceptance_rate |
float |
|
conversion_rate |
float |
|
ttr_p50_seconds |
?int |
median time‑to‑redeem |
ttr_p90_seconds |
?int |
p90 time‑to‑redeem |
The seconds‑diff expression is driver‑aware (EXTRACT(EPOCH …) on pgsql, TIMESTAMPDIFF on
MySQL/MariaDB, julianday(…) * 86400 on SQLite), so percentiles compute correctly on every supported
database.
ADR
ADR · Percentile in SQL, not in PHP
Problem. A percentile needs an ordered dataset. The simple implementation loads every redemption
into PHP and sorts in memory.
Decision. Order in SQL and fetch the single row at the nearest‑rank offset
(OFFSET … LIMIT 1.
Consequences. Memory stays bounded regardless of corpus size (host rule R3 — never load a whole
sweepable set into memory). The cost is two queries (a count then the offset fetch) instead of one,
a negligible price for O(1) memory.
ADR · Read model over canonical rows, not a rollup
Problem. Aggregates can be served from a maintained rollup table (fast) or recomputed from the
canonical rows (always correct).
Decision. Recompute from the canonical tables on every call.
Consequences. The numbers never drift from the raw rows — a corrected or anonymized row is
immediately reflected, and there is no rollup job to miss. For very large corpora a cached projection
can be layered on top later; correctness is the default.
Worked example
use Padosoft\Invitations\Services\MetricsService;
$m = app(MetricsService::class)->summary(campaignId: 7, sinceDays: 30);
// e.g. ['k_factor' => 1.2143, 'acceptance_rate' => 0.64, 'conversion_rate' => 0.41,
// 'ttr_p50_seconds' => 3600, 'ttr_p90_seconds' => 86400, …]
if ($m['k_factor'] >= 1.0) {
// Self-sustaining loop — the campaign grows without paid acquisition.
}
The same summary is exposed over the HTTP API
(GET /api/admin/invitations/metrics) and the InviteMetricsTool MCP tool —
one core service, three surfaces.
GDPR anonymization overwrites PII columns in place and never deletes rows, so a retention sweep or
an erasure request leaves current_uses, the funnel counts, and the K‑factor unchanged. See
GDPR & data privacy.