extract_day1_churn_users.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. const csv = require('csv')
  2. const fs = require('fs')
  3. const minimist = require('minimist')
  4. const { User } = require('../app/src/models/User')
  5. /**
  6. * This script extracts users who churned after day 1 - ie. their last session was within 24 hours of registering
  7. *
  8. * It will:
  9. * — Find users whose lastActive is within 24 hours of their signUpDate
  10. * — Filter for a configurable lookback period (default: 6 months)
  11. * — Export user IDs and email addresses to CSV
  12. *
  13. * Usage:
  14. * - Locally:
  15. * - docker compose exec web bash
  16. * - node scripts/extract_day1_churn_users.js
  17. * - On the server:
  18. * - rake connect:app[staging,web]
  19. * - node scripts/extract_day1_churn_users.js
  20. * - exit
  21. * - kubectl cp web-standalone-prod-XXXXX:/tmp/day1_churn_users.csv ~/day1_churn_users.csv
  22. */
  23. function usage() {
  24. console.log(
  25. `
  26. Day 1 Churn Users extraction, outputs to /tmp/day1_churn_users.csv
  27. Usage:
  28. node scripts/extract_day1_churn_users.js [--lookbackMonths=<months>] [--outputPath=<path>] [--sampleSize=<number>] [--excludeRecentDays=<days>] [--includeLastActive] [--includeHoursActive]
  29. Options:
  30. --help Show this screen
  31. --lookbackMonths=<months> Number of months to look back for registrations (default: 6)
  32. --outputPath=<path> Output file path (default: /tmp/day1_churn_users.csv)
  33. --sampleSize=<number> Maximum number of users to randomly sample per month (default: all users)
  34. --excludeRecentDays=<days> Exclude users who registered in the last X days to avoid premature churn classification (default: 7)
  35. --includeLastActive Include lastActive column in the output CSV (default: false)
  36. --includeHoursActive Include hoursActive column in the output CSV (default: false)
  37. Description:
  38. This script identifies users who churned after day 1, meaning their last activity
  39. was within 24 hours of their registration date. It looks for users who:
  40. 1. Registered within the specified lookback period
  41. 2. Have a lastActive timestamp
  42. 3. Their lastActive is <= 24 hours after their signUpDate
  43. 4. Did not register within the recent exclusion period
  44. Grace Period:
  45. The --excludeRecentDays parameter prevents prematurely marking users as churned.
  46. For example, with --excludeRecentDays=7, users who registered in the last 7 days
  47. will be excluded from the analysis.
  48. Sampling:
  49. When --sampleSize is specified, the script will add a MongoDB $sample stage to
  50. randomly sample up to that number of users from each month within the lookback
  51. period. For example, with --sampleSize=100 and --lookbackMonths=6, you'll get
  52. up to 100 randomly selected users for each of the 6 months, for a maximum of
  53. 600 users total.
  54. `
  55. )
  56. }
  57. function parseArgs() {
  58. const argv = minimist(process.argv.slice(2), {
  59. string: ['outputPath'],
  60. number: ['lookbackMonths', 'sampleSize', 'excludeRecentDays'],
  61. bool: ['help', 'includeLastActive', 'includeHoursActive'],
  62. default: {
  63. help: false,
  64. lookbackMonths: 6,
  65. outputPath: '/tmp/day1_churn_users.csv',
  66. sampleSize: null, // null => return all users
  67. excludeRecentDays: 7, // Exclude users who registered in the last 7 days
  68. includeLastActive: false,
  69. includeHoursActive: false,
  70. },
  71. })
  72. if (argv.help) {
  73. usage()
  74. process.exit(0)
  75. }
  76. return argv
  77. }
  78. async function getDay1ChurnUsers({
  79. lookbackMonths,
  80. sampleSize,
  81. excludeRecentDays,
  82. }) {
  83. // Calculate the actual lookback date used in queries (first day of the oldest month)
  84. const lookbackDate = new Date()
  85. lookbackDate.setMonth(lookbackDate.getMonth() - lookbackMonths)
  86. lookbackDate.setDate(1)
  87. lookbackDate.setHours(0, 0, 0, 0)
  88. const exclusionDate = new Date()
  89. exclusionDate.setDate(exclusionDate.getDate() - excludeRecentDays)
  90. console.log(
  91. `Looking for users who registered after: ${lookbackDate.toISOString()}`
  92. )
  93. console.log(
  94. `Excluding users who registered after: ${exclusionDate.toISOString()} (last ${excludeRecentDays} days)`
  95. )
  96. const allChurnUsers = []
  97. for (let monthOffset = 0; monthOffset < lookbackMonths; monthOffset++) {
  98. const monthStart = new Date()
  99. monthStart.setMonth(monthStart.getMonth() - monthOffset - 1)
  100. monthStart.setDate(1)
  101. monthStart.setHours(0, 0, 0, 0)
  102. const monthEnd = new Date(monthStart)
  103. monthEnd.setMonth(monthEnd.getMonth() + 1)
  104. // Skip months that would include users in the exclusion period
  105. if (monthEnd > exclusionDate) {
  106. // Adjust monthEnd to the exclusion date if the month overlaps
  107. if (monthStart < exclusionDate) {
  108. monthEnd.setTime(exclusionDate.getTime())
  109. } else {
  110. continue
  111. }
  112. }
  113. const monthKey = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
  114. console.log(
  115. `Processing month ${monthKey} (${monthStart.toISOString()} to ${monthEnd.toISOString()})`
  116. )
  117. const pipeline = [
  118. // Match users who registered in this month and have a lastActive property
  119. {
  120. $match: {
  121. signUpDate: {
  122. $gte: monthStart,
  123. $lt: monthEnd,
  124. },
  125. lastActive: { $exists: true, $ne: null },
  126. },
  127. },
  128. // Compute the time between registration and last active
  129. {
  130. $addFields: {
  131. timeDiffHours: {
  132. $divide: [
  133. { $subtract: ['$lastActive', '$signUpDate'] },
  134. 1000 * 60 * 60, // Convert milliseconds to hours
  135. ],
  136. },
  137. },
  138. },
  139. // Filter for day 1 churn (0-24 hours)
  140. {
  141. $match: {
  142. timeDiffHours: { $gte: 0, $lte: 24 },
  143. },
  144. },
  145. {
  146. $project: {
  147. _id: 1,
  148. email: 1,
  149. signUpDate: 1,
  150. lastActive: 1,
  151. timeDiffHours: 1,
  152. },
  153. },
  154. ]
  155. // Add sampling stage if specified
  156. if (sampleSize && sampleSize > 0) {
  157. pipeline.push({ $sample: { size: sampleSize } })
  158. }
  159. const monthUsers = await User.aggregate(pipeline).exec()
  160. console.log(
  161. `Month ${monthKey}: Found ${monthUsers.length} day 1 churn users`
  162. )
  163. const formattedUsers = monthUsers.map(user => ({
  164. userId: user._id.toString(),
  165. email: user.email,
  166. signUpDate: new Date(user.signUpDate).toISOString(),
  167. lastActive: new Date(user.lastActive).toISOString(),
  168. hoursActive: user.timeDiffHours.toFixed(2),
  169. }))
  170. allChurnUsers.push(...formattedUsers)
  171. }
  172. console.log(`Total users collected: ${allChurnUsers.length}`)
  173. return allChurnUsers
  174. }
  175. async function runScript() {
  176. const args = parseArgs()
  177. console.log(
  178. `Starting Day 1 churn extraction with lookback period: ${args.lookbackMonths} months`
  179. )
  180. console.log(
  181. `Excluding users who registered in the last ${args.excludeRecentDays} days`
  182. )
  183. if (args.sampleSize) {
  184. console.log(`Sampling enabled: maximum ${args.sampleSize} users per month`)
  185. } else {
  186. console.log('No sampling - returning all users')
  187. }
  188. if (args.includeLastActive) {
  189. console.log('Including lastActive column in output')
  190. }
  191. if (args.includeHoursActive) {
  192. console.log('Including hoursActive column in output')
  193. }
  194. const churnUsers = await getDay1ChurnUsers(args)
  195. if (churnUsers.length === 0) {
  196. console.log('No day 1 churn users found for the specified period')
  197. return
  198. }
  199. console.log(`Writing ${churnUsers.length} users to ${args.outputPath}...`)
  200. const columns = ['userId', 'email', 'signUpDate']
  201. if (args.includeLastActive) {
  202. columns.push('lastActive')
  203. }
  204. if (args.includeHoursActive) {
  205. columns.push('hoursActive')
  206. }
  207. csv.stringify(
  208. churnUsers,
  209. {
  210. header: true,
  211. columns,
  212. },
  213. function (err, output) {
  214. if (err) {
  215. console.error('Error writing CSV output:', err)
  216. process.exit(1)
  217. }
  218. fs.writeFileSync(args.outputPath, output)
  219. console.log(
  220. `Successfully wrote ${churnUsers.length} day 1 churn users to ${args.outputPath}`
  221. )
  222. process.exit(0)
  223. }
  224. )
  225. }
  226. runScript().catch(err => {
  227. console.error('Script failed:', err)
  228. process.exit(1)
  229. })