extract_day1_churn_users.mjs 8.1 KB

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