| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 |
- const csv = require('csv')
- const fs = require('fs')
- const minimist = require('minimist')
- const { User } = require('../app/src/models/User')
- /**
- * This script extracts users who churned after day 1 - ie. their last session was within 24 hours of registering
- *
- * It will:
- * — Find users whose lastActive is within 24 hours of their signUpDate
- * — Filter for a configurable lookback period (default: 6 months)
- * — Export user IDs and email addresses to CSV
- *
- * Usage:
- * - Locally:
- * - docker compose exec web bash
- * - node scripts/extract_day1_churn_users.js
- * - On the server:
- * - rake connect:app[staging,web]
- * - node scripts/extract_day1_churn_users.js
- * - exit
- * - kubectl cp web-standalone-prod-XXXXX:/tmp/day1_churn_users.csv ~/day1_churn_users.csv
- */
- function usage() {
- console.log(
- `
- Day 1 Churn Users extraction, outputs to /tmp/day1_churn_users.csv
- Usage:
- node scripts/extract_day1_churn_users.js [--lookbackMonths=<months>] [--outputPath=<path>] [--sampleSize=<number>] [--excludeRecentDays=<days>] [--includeLastActive] [--includeHoursActive]
- Options:
- --help Show this screen
- --lookbackMonths=<months> Number of months to look back for registrations (default: 6)
- --outputPath=<path> Output file path (default: /tmp/day1_churn_users.csv)
- --sampleSize=<number> Maximum number of users to randomly sample per month (default: all users)
- --excludeRecentDays=<days> Exclude users who registered in the last X days to avoid premature churn classification (default: 7)
- --includeLastActive Include lastActive column in the output CSV (default: false)
- --includeHoursActive Include hoursActive column in the output CSV (default: false)
- Description:
- This script identifies users who churned after day 1, meaning their last activity
- was within 24 hours of their registration date. It looks for users who:
- 1. Registered within the specified lookback period
- 2. Have a lastActive timestamp
- 3. Their lastActive is <= 24 hours after their signUpDate
- 4. Did not register within the recent exclusion period
- Grace Period:
- The --excludeRecentDays parameter prevents prematurely marking users as churned.
- For example, with --excludeRecentDays=7, users who registered in the last 7 days
- will be excluded from the analysis.
- Sampling:
- When --sampleSize is specified, the script will add a MongoDB $sample stage to
- randomly sample up to that number of users from each month within the lookback
- period. For example, with --sampleSize=100 and --lookbackMonths=6, you'll get
- up to 100 randomly selected users for each of the 6 months, for a maximum of
- 600 users total.
- `
- )
- }
- function parseArgs() {
- const argv = minimist(process.argv.slice(2), {
- string: ['outputPath'],
- number: ['lookbackMonths', 'sampleSize', 'excludeRecentDays'],
- bool: ['help', 'includeLastActive', 'includeHoursActive'],
- default: {
- help: false,
- lookbackMonths: 6,
- outputPath: '/tmp/day1_churn_users.csv',
- sampleSize: null, // null => return all users
- excludeRecentDays: 7, // Exclude users who registered in the last 7 days
- includeLastActive: false,
- includeHoursActive: false,
- },
- })
- if (argv.help) {
- usage()
- process.exit(0)
- }
- return argv
- }
- async function getDay1ChurnUsers({
- lookbackMonths,
- sampleSize,
- excludeRecentDays,
- }) {
- // Calculate the actual lookback date used in queries (first day of the oldest month)
- const lookbackDate = new Date()
- lookbackDate.setMonth(lookbackDate.getMonth() - lookbackMonths)
- lookbackDate.setDate(1)
- lookbackDate.setHours(0, 0, 0, 0)
- const exclusionDate = new Date()
- exclusionDate.setDate(exclusionDate.getDate() - excludeRecentDays)
- console.log(
- `Looking for users who registered after: ${lookbackDate.toISOString()}`
- )
- console.log(
- `Excluding users who registered after: ${exclusionDate.toISOString()} (last ${excludeRecentDays} days)`
- )
- const allChurnUsers = []
- for (let monthOffset = 0; monthOffset < lookbackMonths; monthOffset++) {
- const monthStart = new Date()
- monthStart.setMonth(monthStart.getMonth() - monthOffset - 1)
- monthStart.setDate(1)
- monthStart.setHours(0, 0, 0, 0)
- const monthEnd = new Date(monthStart)
- monthEnd.setMonth(monthEnd.getMonth() + 1)
- // Skip months that would include users in the exclusion period
- if (monthEnd > exclusionDate) {
- // Adjust monthEnd to the exclusion date if the month overlaps
- if (monthStart < exclusionDate) {
- monthEnd.setTime(exclusionDate.getTime())
- } else {
- continue
- }
- }
- const monthKey = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
- console.log(
- `Processing month ${monthKey} (${monthStart.toISOString()} to ${monthEnd.toISOString()})`
- )
- const pipeline = [
- // Match users who registered in this month and have a lastActive property
- {
- $match: {
- signUpDate: {
- $gte: monthStart,
- $lt: monthEnd,
- },
- lastActive: { $exists: true, $ne: null },
- },
- },
- // Compute the time between registration and last active
- {
- $addFields: {
- timeDiffHours: {
- $divide: [
- { $subtract: ['$lastActive', '$signUpDate'] },
- 1000 * 60 * 60, // Convert milliseconds to hours
- ],
- },
- },
- },
- // Filter for day 1 churn (0-24 hours)
- {
- $match: {
- timeDiffHours: { $gte: 0, $lte: 24 },
- },
- },
- {
- $project: {
- _id: 1,
- email: 1,
- signUpDate: 1,
- lastActive: 1,
- timeDiffHours: 1,
- },
- },
- ]
- // Add sampling stage if specified
- if (sampleSize && sampleSize > 0) {
- pipeline.push({ $sample: { size: sampleSize } })
- }
- const monthUsers = await User.aggregate(pipeline).exec()
- console.log(
- `Month ${monthKey}: Found ${monthUsers.length} day 1 churn users`
- )
- const formattedUsers = monthUsers.map(user => ({
- userId: user._id.toString(),
- email: user.email,
- signUpDate: new Date(user.signUpDate).toISOString(),
- lastActive: new Date(user.lastActive).toISOString(),
- hoursActive: user.timeDiffHours.toFixed(2),
- }))
- allChurnUsers.push(...formattedUsers)
- }
- console.log(`Total users collected: ${allChurnUsers.length}`)
- return allChurnUsers
- }
- async function runScript() {
- const args = parseArgs()
- console.log(
- `Starting Day 1 churn extraction with lookback period: ${args.lookbackMonths} months`
- )
- console.log(
- `Excluding users who registered in the last ${args.excludeRecentDays} days`
- )
- if (args.sampleSize) {
- console.log(`Sampling enabled: maximum ${args.sampleSize} users per month`)
- } else {
- console.log('No sampling - returning all users')
- }
- if (args.includeLastActive) {
- console.log('Including lastActive column in output')
- }
- if (args.includeHoursActive) {
- console.log('Including hoursActive column in output')
- }
- const churnUsers = await getDay1ChurnUsers(args)
- if (churnUsers.length === 0) {
- console.log('No day 1 churn users found for the specified period')
- return
- }
- console.log(`Writing ${churnUsers.length} users to ${args.outputPath}...`)
- const columns = ['userId', 'email', 'signUpDate']
- if (args.includeLastActive) {
- columns.push('lastActive')
- }
- if (args.includeHoursActive) {
- columns.push('hoursActive')
- }
- csv.stringify(
- churnUsers,
- {
- header: true,
- columns,
- },
- function (err, output) {
- if (err) {
- console.error('Error writing CSV output:', err)
- process.exit(1)
- }
- fs.writeFileSync(args.outputPath, output)
- console.log(
- `Successfully wrote ${churnUsers.length} day 1 churn users to ${args.outputPath}`
- )
- process.exit(0)
- }
- )
- }
- runScript().catch(err => {
- console.error('Script failed:', err)
- process.exit(1)
- })
|