Day-One Retention: Do Not Count an Unfinished Day as Churn
A day-one retention report can fall simply because today's newest cohort has not had a complete opportunity to return. Treating that cohort as zero does not measure disengagement. It measures a mixture of behavior and unfinished observation.
Separate complete return windows from pending windows before calculating a final rate. Record the starting event, return event, identity, timezone, cohort-entry policy, and completeness cutoff. Only then does “day one” describe a reproducible metric rather than a label on a chart.
This is an engineering note from the SensorFlow maintainers, using synthetic event data and an independent offline calculation. It is not a benchmark, customer result, or a claim that a vendor's full retention engine has been reproduced.
What does day-one retention mean here?
In this tutorial, day-one retention is the fraction of accounts that complete their first signup on a UTC calendar date and trigger FeatureUsed on the immediately following UTC date. A cohort enters the final denominator only after that entire return date is declared complete. An unfinished window is pending, not zero.
The word “first” is part of this policy. A recurring-start-event analysis can enroll the same account on multiple cohort dates; that is a different question. Likewise, “any event” and “a meaningful feature action” are different return conditions.
Sensors Data's retention guide separates the initial and subsequent behaviors and describes specific-date, interval, and continuous retention. Choose one behavior model before comparing results. Do not read a high rate for “opened the app” as evidence for repeated use of a core feature.
1. Calendar day and rolling time are different contracts
An account signs up at 23:59 UTC and uses a feature at 00:01 UTC. Under the next-calendar-date policy, that return qualifies for day one. Only two minutes elapsed, so it is not evidence of a return in a 24-to-48-hour window.
Amplitude's official time documentation describes both rolling 24-hour and strict calendar-date measurements. Calendar measurement depends on the project timezone. Our code explicitly uses UTC calendar days; it does not claim to match every configuration or the details of Amplitude's calculation.
Write the time definition in the report description, not only in a query comment:
- Initial event: first known
SignupCompletedfor an account. - Return event: at least one
FeatureUsedon the next calendar date. - Identity: project-scoped account ID, not a device or natural person.
- Timezone: UTC in the executable fixture.
- Entry range: the dates of initial events, not a filter that accidentally cuts off return events.
- Finality: the complete-through watermark supplied by the data pipeline.
If your business uses another timezone, compute its actual calendar boundaries. Do not replace them with “timestamp plus 24 hours” across daylight-saving transitions and assume you still have local calendar days. The MDN Date reference explains the difference between UTC time values and local-time methods; keep host timezone out of the analysis contract.
2. Why the newest cohort should stay pending
Suppose the observation cutoff is October 13 at 08:00 UTC. The return window for the October 12 signup cohort runs until October 14 at 00:00 UTC. It is unfinished even if some people have already returned.
Including early returners but excluding early nonreturners would inflate the result. Including everyone and treating unseen returns as zero would depress it. A conservative final report excludes the entire unfinished cohort from both numerator and denominator, while listing it separately as pending.
Amplitude's retention-calculation documentation notes that incomplete cells are excluded from overall totals. That supports the general need to check eligibility; the code below uses its own clearly stated complete-window policy, not a hidden approximation of a platform.
A pipeline watermark is stronger than the clock on your laptop. Midnight passing does not prove that queued mobile events, delayed imports, or offline uploads have arrived. The fixture assumes the watermark is valid for both initial and return events in its scope; a real pipeline must establish that evidence or report a provisional result.
3. A runnable cohort fixture
Save the following as retention-fixture.mjs and run node retention-fixture.mjs with Node.js installed. It requires no account, SDK, database, or license.
The sample contains five synthetic accounts, duplicate delivery, a same-day action that does not qualify, a return exactly at an excluded boundary, and a pending cohort with an early observed return. The schema is a normalized teaching format, not an SDK transport payload or a SensorFlow native table.
import assert from 'node:assert/strict';
const event = (id, account, name, time) => ({
project: 'demo', event_id: id, account_id: account,
event: name, occurred_at: time
});
const signupA = event('a0', 'a', 'SignupCompleted', '2026-10-10T23:59:00.000Z');
const rows = [
signupA, { ...signupA },
event('a1', 'a', 'FeatureUsed', '2026-10-11T00:01:00.000Z'),
event('b0', 'b', 'SignupCompleted', '2026-10-10T10:00:00.000Z'),
event('b1', 'b', 'FeatureUsed', '2026-10-10T18:00:00.000Z'),
event('c0', 'c', 'SignupCompleted', '2026-10-11T05:00:00.000Z'),
event('c1', 'c', 'FeatureUsed', '2026-10-12T06:00:00.000Z'),
event('d0', 'd', 'SignupCompleted', '2026-10-12T03:00:00.000Z'),
event('d1', 'd', 'FeatureUsed', '2026-10-13T04:00:00.000Z'),
event('e0', 'e', 'SignupCompleted', '2026-10-11T23:00:00.000Z'),
event('e1', 'e', 'FeatureUsed', '2026-10-13T00:00:00.000Z')
];
const DAY = 86400000; // UTC fixture only, not a local DST calendar utility
const day = time => Math.floor(time / DAY);
const label = d => new Date(d * DAY).toISOString().slice(0, 10);
const accountKey = e => JSON.stringify([e.project, e.account_id]);
function retention(input, completeThrough) {
assert.ok(Number.isFinite(completeThrough), 'invalid completeness watermark');
const events = new Map();
for (const e of input) {
for (const f of ['project', 'event_id', 'account_id', 'event']) {
assert.equal(typeof e[f], 'string');
assert.ok(e[f].length > 0);
}
assert.ok(/Z$/.test(e.occurred_at), 'explicit UTC required');
const time = Date.parse(e.occurred_at);
assert.ok(Number.isFinite(time), 'invalid time');
const key = JSON.stringify([e.project, e.event_id]);
const value = { ...e, time };
if (events.has(key)) assert.deepEqual(events.get(key), value, 'conflicting retry');
else events.set(key, value);
}
// Watermark: input covers the relevant history, complete for times < cutoff.
const observed = [...events.values()].filter(e => e.time < completeThrough);
const firstSignup = new Map(), featureDays = new Map();
for (const e of observed) {
const key = accountKey(e);
if (e.event === 'SignupCompleted') {
firstSignup.set(key, Math.min(firstSignup.get(key) ?? Infinity, e.time));
}
if (e.event === 'FeatureUsed') {
if (!featureDays.has(key)) featureDays.set(key, new Set());
featureDays.get(key).add(day(e.time));
}
}
const cohorts = new Map();
for (const [key, time] of firstSignup) {
const entryDay = day(time), returnDay = entryDay + 1;
const complete = (returnDay + 1) * DAY <= completeThrough;
if (!cohorts.has(entryDay)) cohorts.set(entryDay, {
cohort: label(entryDay), status: complete ? 'complete' : 'pending',
entered: 0, retained: complete ? 0 : null
});
const group = cohorts.get(entryDay);
group.entered++;
if (complete && featureDays.get(key)?.has(returnDay)) group.retained++;
}
const result = [...cohorts.entries()].sort((a, b) => a[0] - b[0])
.map(([, c]) => ({ ...c, rate: c.retained === null ? null : c.retained / c.entered }));
const mature = result.filter(c => c.status === 'complete');
const eligible = mature.reduce((n, c) => n + c.entered, 0);
const returned = mature.reduce((n, c) => n + c.retained, 0);
return { cohorts: result, eligible, returned, rate: eligible ? returned / eligible : null };
}
const cutoff = Date.parse('2026-10-13T08:00:00.000Z');
const output = retention(rows, cutoff);
assert.deepEqual(output, {
cohorts: [
{ cohort: '2026-10-10', status: 'complete', entered: 2, retained: 1, rate: 0.5 },
{ cohort: '2026-10-11', status: 'complete', entered: 2, retained: 1, rate: 0.5 },
{ cohort: '2026-10-12', status: 'pending', entered: 1, retained: null, rate: null }
], eligible: 4, returned: 2, rate: 0.5
});
assert.deepEqual(retention([...rows].reverse(), cutoff), output);
assert.deepEqual(retention([...rows, rows[2]], cutoff), output);
assert.equal(retention([], cutoff).rate, null);
assert.throws(() => retention([signupA, { ...signupA, account_id: 'other' }], cutoff));
assert.throws(() => retention([{ ...signupA, occurred_at: 'bad' }], cutoff));
assert.equal(retention(rows, Date.parse('2026-10-14T00:00:00.000Z')).eligible, 5);
assert.equal(retention(rows, Date.parse('2026-10-14T00:00:00.000Z')).returned, 3);
console.log(JSON.stringify(output, null, 2));
console.log('PASS: expected cohorts, order, retries, empty, conflicts, time, maturity');
The final rate is 2 returned accounts out of 4 eligible accounts, or 50%. The fifth account is pending even though a return has been observed. It becomes eligible only when the entire next-day window is complete; the boundary test then yields five eligible accounts and three returners.
All numbers are generated by this synthetic fixture. There is no assertion that the production dataset is complete, that the SDK has been integrated, or that a real product's retention is 50%.
The code uses Map to keep project-scoped event and account keys, Set to count whether a qualifying date occurred, and Node assertions to encode expected results. It checks explicit UTC and parsable time, not a complete production timestamp schema.
4. Why exact-day retention does not mean any later return
Account E returns at October 13, 00:00 UTC after a signup on October 11. That is outside the October 12 return date, so it does not qualify for exact day one. It may qualify for another retention definition, but changing the definition to make this example pass would change the question.
“Return on day one,” “return on or after day one,” and “return during the first week” can each be useful. They must have different names and separately tested boundaries. Do not combine their numerators in a single rate.
The same-day action for account B is also excluded. A registration followed by immediate feature use is an activation signal under another contract, not next-day retention under this one. If onboarding is the business question, measure activation explicitly instead of relabeling it retention.
5. A first-signup cohort needs enough history
The earliest signup in a seven-day export is not necessarily the account's first-ever signup. The code assumes the input covers the relevant registration history or an authoritative first-signup field has already established cohort entry.
If historical events are missing, name the result “first observed in this dataset,” or obtain authoritative account creation information. Do not quietly count a returning account as newly acquired because the old record fell outside a storage-retention window.
Similarly, a duplicate logical registration with a new event ID should not create a second account enrollment. This fixture uses the earliest timestamp per project-scoped account for enrollment. A production model must still investigate conflicting registration times and account merges rather than treating a minimum timestamp as a universal identity solution.
6. Missing data and immature time are different problems
An immature cohort has not finished its return window. A mature cohort can still have missing events because collection was disabled, ingestion failed, or a batch arrived late. Neither should be explained as a sudden product change without checking the pipeline.
Keep at least these operational annotations with the report:
- Window status: complete or pending.
- The watermark and the event sources it covers.
- Last successful ingest or backfill for each source.
- Instrumentation and metric-policy versions.
- Known gaps in client versions, platforms, or consent-dependent collection.
When delayed events invalidate a previous completeness assumption, preserve the original report version and issue a revised result. Hiding the change makes analysts chase a nonexistent product regression.
The script cannot prove completeness from an event array. Its completeThrough argument is an assumption that the caller must support. This is why it is safer to show an explicit pending state than to replace every absent event with a confident zero.
7. Aggregate counts, not an unweighted average of percentages
For a final overall result, sum returned and eligible accounts across the included cohorts, then divide. A cohort of two accounts should not receive the same weight as one containing two thousand merely because both occupy one row.
This tutorial assigns each account to one first-signup cohort, so the overall denominator is four distinct eligible account keys. A recurring-start-event model can enroll the same account on several dates; its cohort-entry total is not automatically an overall unique-person count.
When comparing a saved dashboard with a migration query, check both the weighting and the enrollment rule. “The percentages differ” is not enough evidence to call one implementation broken; first establish that the definitions match.
8. A practical release checklist
- Agree on an initial event and a return event tied to a real product question.
- Write the timezone and exact calendar or rolling-window definition.
- Decide whether entry is first-ever, first-observed, or recurring.
- Preserve stable account identity and project boundaries in the stored events.
- Add tests around midnight, duplicate delivery, the exclusive end boundary, and empty input.
- Display incomplete cohorts separately, including already-observed early returns.
- Establish a real completeness or provisional-data policy for late events.
- Compare row-level membership before interpreting a change in the overall rate.
A passing fixture verifies your chosen calculation on a controlled input. Next, use staging test accounts to verify that the actual events arrive with the intended identity, timestamp, and semantics. The mathematics and the ingestion test are separate evidence.
9. Where a self-hosted event pipeline helps
SensorFlow is an open-source, self-hosted event data pipeline for teams retaining the official Sensors Data SDK ecosystem. Its open-source deployment provides synthetic demo data without a license; real SDK reception requires commercial license activation. A team can inspect stored events and define its own analysis policy, but should not assume that changing the destination reproduces every vendor's retention or identity configuration.
Use the SensorFlow deployment and integration documentation if you need a staging destination. Start with one test cohort and verify the raw records before building a larger dashboard. This article's offline code is not a test of production SDK compatibility or a built-in SensorFlow retention feature.
Keep the final question small: which fully observed accounts returned under this specific contract? That answer is useful even when an unfinished cohort remains blank. A blank that accurately describes pending evidence is better than a zero that pretends the future has already happened.
This article was drafted with AI assistance, checked against official documentation, and its synthetic code was executed locally. No production dataset, customer outcome, or performance result is represented by the example.
评论
发表评论