Skip to content

Commit fa03570

Browse files
authored
Fixed top sources not deduplicating free->paid conversions (#24570)
ref https://linear.app/ghost/issue/ENG-2496/analytics-growth-top-sources-sources-free-members-is-not-showing - in other places (post stats) we 'deduplicate' member counts where a free signup also includes a paid conversion in the same time frame; this was not handled in Sources - added unit test coverage to describe cases/behavior We've defined member attribution in the Analytics apps as only counting a free signup if the user didn't also sign up as a paid member in the same timeframe. This makes it a bit easier to reason about your total member count when looking at the free/paid split.
1 parent eb6f8eb commit fa03570

File tree

2 files changed

+368
-65
lines changed

2 files changed

+368
-65
lines changed

ghost/core/core/server/services/stats/ReferrersStatsService.js

Lines changed: 101 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -250,52 +250,6 @@ class ReferrersStatsService {
250250
return rows;
251251
}
252252

253-
/**
254-
* Fetch paid conversion sources with date range
255-
* @param {string} startDate
256-
* @param {string} endDate
257-
* @returns {Promise<PaidConversionsCountStatDate[]>}
258-
**/
259-
async fetchPaidConversionSourcesWithRange(startDate, endDate) {
260-
const knex = this.knex;
261-
const startDateTime = moment.utc(startDate).startOf('day').format('YYYY-MM-DD HH:mm:ss');
262-
const endDateTime = moment.utc(endDate).endOf('day').format('YYYY-MM-DD HH:mm:ss');
263-
264-
const rows = await knex('members_subscription_created_events')
265-
.select(knex.raw(`DATE(created_at) as date`))
266-
.select(knex.raw(`COUNT(*) as paid_conversions`))
267-
.select(knex.raw(`referrer_source as source`))
268-
.where('created_at', '>=', startDateTime)
269-
.where('created_at', '<=', endDateTime)
270-
.groupBy('date', 'referrer_source')
271-
.orderBy('date');
272-
273-
return rows;
274-
}
275-
276-
/**
277-
* Fetch signup sources with date range
278-
* @param {string} startDate
279-
* @param {string} endDate
280-
* @returns {Promise<SignupCountStatDate[]>}
281-
**/
282-
async fetchSignupSourcesWithRange(startDate, endDate) {
283-
const knex = this.knex;
284-
const startDateTime = moment.utc(startDate).startOf('day').format('YYYY-MM-DD HH:mm:ss');
285-
const endDateTime = moment.utc(endDate).endOf('day').format('YYYY-MM-DD HH:mm:ss');
286-
287-
const rows = await knex('members_created_events')
288-
.select(knex.raw(`DATE(created_at) as date`))
289-
.select(knex.raw(`COUNT(*) as signups`))
290-
.select(knex.raw(`referrer_source as source`))
291-
.where('created_at', '>=', startDateTime)
292-
.where('created_at', '<=', endDateTime)
293-
.groupBy('date', 'referrer_source')
294-
.orderBy('date');
295-
296-
return rows;
297-
}
298-
299253
/**
300254
* Fetch MRR sources with date range
301255
* @param {string} startDate
@@ -326,6 +280,76 @@ class ReferrersStatsService {
326280
return rows;
327281
}
328282

283+
/**
284+
* Fetch deduplicated member counts by source with date range
285+
* Returns both free signups (excluding those who converted) and paid conversions
286+
* @param {string} startDate
287+
* @param {string} endDate
288+
* @returns {Promise<{source: string, signups: number, paid_conversions: number}[]>}
289+
**/
290+
async fetchMemberCountsBySource(startDate, endDate) {
291+
const knex = this.knex;
292+
const startDateTime = moment.utc(startDate).startOf('day').format('YYYY-MM-DD HH:mm:ss');
293+
const endDateTime = moment.utc(endDate).endOf('day').format('YYYY-MM-DD HH:mm:ss');
294+
295+
// Query 1: Free members who haven't converted to paid within the same time window
296+
const freeSignupsQuery = knex('members_created_events as mce')
297+
.select('mce.referrer_source as source')
298+
.select(knex.raw('COUNT(DISTINCT mce.member_id) as signups'))
299+
.leftJoin('members_subscription_created_events as msce', function () {
300+
this.on('mce.member_id', '=', 'msce.member_id')
301+
// Only join if the conversion happened within the same time window
302+
.andOn('msce.created_at', '>=', knex.raw('?', [startDateTime]))
303+
.andOn('msce.created_at', '<=', knex.raw('?', [endDateTime]));
304+
})
305+
.where('mce.created_at', '>=', startDateTime)
306+
.where('mce.created_at', '<=', endDateTime)
307+
.whereNull('msce.id')
308+
.groupBy('mce.referrer_source');
309+
310+
// Query 2: Paid conversions
311+
const paidConversionsQuery = knex('members_subscription_created_events as msce')
312+
.select('msce.referrer_source as source')
313+
.select(knex.raw('COUNT(DISTINCT msce.member_id) as paid_conversions'))
314+
.where('msce.created_at', '>=', startDateTime)
315+
.where('msce.created_at', '<=', endDateTime)
316+
.groupBy('msce.referrer_source');
317+
318+
// Execute both queries in parallel
319+
const [freeResults, paidResults] = await Promise.all([
320+
freeSignupsQuery,
321+
paidConversionsQuery
322+
]);
323+
324+
// Combine results by source
325+
const sourceMap = new Map();
326+
327+
// Add free signups
328+
freeResults.forEach((row) => {
329+
sourceMap.set(row.source, {
330+
source: row.source,
331+
signups: parseInt(row.signups) || 0,
332+
paid_conversions: 0
333+
});
334+
});
335+
336+
// Add paid conversions
337+
paidResults.forEach((row) => {
338+
const existing = sourceMap.get(row.source);
339+
if (existing) {
340+
existing.paid_conversions = parseInt(row.paid_conversions) || 0;
341+
} else {
342+
sourceMap.set(row.source, {
343+
source: row.source,
344+
signups: 0,
345+
paid_conversions: parseInt(row.paid_conversions) || 0
346+
});
347+
}
348+
});
349+
350+
return Array.from(sourceMap.values());
351+
}
352+
329353
/**
330354
* Return aggregated attribution sources for a date range, grouped by source only (not by date)
331355
* This is used for "Top Sources" tables that need server-side sorting
@@ -336,35 +360,47 @@ class ReferrersStatsService {
336360
* @returns {Promise<{data: AttributionCountStatWithMrr[], meta: {}}>}
337361
*/
338362
async getTopSourcesWithRange(startDate, endDate, orderBy = 'signups desc', limit = 50) {
339-
const paidConversionEntries = await this.fetchPaidConversionSourcesWithRange(startDate, endDate);
340-
const signupEntries = await this.fetchSignupSourcesWithRange(startDate, endDate);
341-
const mrrEntries = await this.fetchMrrSourcesWithRange(startDate, endDate);
363+
// Get deduplicated member counts and MRR data in parallel
364+
const [memberCounts, mrrEntries] = await Promise.all([
365+
this.fetchMemberCountsBySource(startDate, endDate),
366+
this.fetchMrrSourcesWithRange(startDate, endDate)
367+
]);
342368

343369
// Aggregate by source (not by date + source)
344370
const sourceMap = new Map();
345371

346-
// Add signup data
347-
signupEntries.forEach((entry) => {
348-
const source = normalizeSource(entry.source);
349-
const existing = sourceMap.get(source) || {source, signups: 0, paid_conversions: 0, mrr: 0};
350-
existing.signups += entry.signups;
351-
sourceMap.set(source, existing);
352-
});
353-
354-
// Add paid conversion data
355-
paidConversionEntries.forEach((entry) => {
372+
// Add member counts (both signups and paid conversions)
373+
memberCounts.forEach((entry) => {
356374
const source = normalizeSource(entry.source);
357-
const existing = sourceMap.get(source) || {source, signups: 0, paid_conversions: 0, mrr: 0};
358-
existing.paid_conversions += entry.paid_conversions;
359-
sourceMap.set(source, existing);
375+
const existing = sourceMap.get(source);
376+
if (existing) {
377+
// Aggregate if the normalized source already exists (e.g., multiple null/empty values)
378+
existing.signups += entry.signups;
379+
existing.paid_conversions += entry.paid_conversions;
380+
} else {
381+
sourceMap.set(source, {
382+
source,
383+
signups: entry.signups,
384+
paid_conversions: entry.paid_conversions,
385+
mrr: 0
386+
});
387+
}
360388
});
361389

362390
// Add MRR data
363391
mrrEntries.forEach((entry) => {
364392
const source = normalizeSource(entry.source);
365-
const existing = sourceMap.get(source) || {source, signups: 0, paid_conversions: 0, mrr: 0};
366-
existing.mrr += entry.mrr;
367-
sourceMap.set(source, existing);
393+
const existing = sourceMap.get(source);
394+
if (existing) {
395+
existing.mrr += entry.mrr;
396+
} else {
397+
sourceMap.set(source, {
398+
source,
399+
signups: 0,
400+
paid_conversions: 0,
401+
mrr: entry.mrr
402+
});
403+
}
368404
});
369405

370406
// Convert to array and sort

0 commit comments

Comments
 (0)