Skip to content

Commit dfdcfac

Browse files
committed
update top freebuff users script
1 parent 478dbc2 commit dfdcfac

File tree

1 file changed

+230
-45
lines changed

1 file changed

+230
-45
lines changed

scripts/top-freebuff-users.ts

Lines changed: 230 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,61 @@ import { db } from '@codebuff/internal/db'
22
import * as schema from '@codebuff/internal/db/schema'
33
import { sql } from 'drizzle-orm'
44

5+
interface UserStats {
6+
userId: string
7+
email: string | null
8+
messageCount: number
9+
totalCredits: number
10+
totalCost: number
11+
totalInputTokens: number
12+
totalOutputTokens: number
13+
totalCacheReadTokens: number
14+
cacheHitRate: number
15+
daysActive: number
16+
avgMessagesPerDay: number
17+
maxMessagesInDay: number
18+
firstMessage: string
19+
lastMessage: string
20+
hourlyDistribution: Map<number, number>
21+
}
22+
523
async function topFreebuffUsers() {
6-
const hoursBack = parseInt(process.argv[2] || '72')
7-
const limit = parseInt(process.argv[3] || '200')
24+
const hoursBack = parseInt(process.argv[2] || '168') // default 1 week
25+
const limit = parseInt(process.argv[3] || '50')
26+
const agentId = process.argv[4] || 'base2-free' // configurable agent ID
827
const cutoff = new Date(Date.now() - hoursBack * 60 * 60 * 1000)
28+
const excludeAgents = ['base2', 'base2-max']
929

10-
console.log(`\nTop ${limit} Freebuff-only users by message count (last ${hoursBack} hours)`)
11-
console.log(`Since: ${cutoff.toISOString()}`)
12-
console.log('Excluding users with any base2 or base2-max messages in this period')
13-
console.log('─'.repeat(90))
30+
console.log(`\n${'='.repeat(100)}`)
31+
console.log(` TOP FREEBUFF USERS - DETAILED STATS (last ${hoursBack} hours)`)
32+
console.log(` Agent: ${agentId}`)
33+
console.log(` Since: ${cutoff.toISOString()}`)
34+
console.log(` Excluding: ${excludeAgents.join(', ')}`)
35+
console.log(`${'='.repeat(100)}\n`)
1436

15-
// Count messages per user where the agent is base2-free
37+
// Get all base2-free messages in the period (excluding users with base2/base2-max)
1638
const results = await db
1739
.select({
1840
userId: schema.message.user_id,
1941
email: schema.user.email,
20-
messageCount: sql<string>`COUNT(*)`,
21-
totalCredits: sql<string>`COALESCE(SUM(${schema.message.credits}), 0)`,
22-
totalCost: sql<string>`COALESCE(SUM(${schema.message.cost}), 0)`,
42+
messageCount: sql<number>`COUNT(*)`,
43+
totalCredits: sql<number>`COALESCE(SUM(${schema.message.credits}), 0)`,
44+
totalCost: sql<number>`COALESCE(SUM(${schema.message.cost}), 0)`,
45+
totalInputTokens: sql<number>`COALESCE(SUM(${schema.message.input_tokens}), 0)`,
46+
totalOutputTokens: sql<number>`COALESCE(SUM(${schema.message.output_tokens}), 0)`,
47+
totalCacheReadTokens: sql<number>`COALESCE(SUM(${schema.message.cache_read_input_tokens}), 0)`,
48+
firstMessage: sql<string>`MIN(${schema.message.finished_at})`,
2349
lastMessage: sql<string>`MAX(${schema.message.finished_at})`,
2450
})
2551
.from(schema.message)
2652
.leftJoin(schema.user, sql`${schema.message.user_id} = ${schema.user.id}`)
2753
.where(
2854
sql`${schema.message.finished_at} >= ${cutoff.toISOString()}
29-
AND ${schema.message.agent_id} = 'base2-free'
55+
AND ${schema.message.agent_id} = ${agentId}
3056
AND ${schema.message.user_id} NOT IN (
3157
SELECT ${schema.message.user_id}
3258
FROM ${schema.message}
33-
WHERE ${schema.message.agent_id} IN ('base2', 'base2-max')
59+
WHERE ${schema.message.agent_id} IN (${sql.join(excludeAgents.map(a => sql`${a}`), sql`, `)})
3460
AND ${schema.message.finished_at} >= ${cutoff.toISOString()}
3561
)`,
3662
)
@@ -39,57 +65,216 @@ async function topFreebuffUsers() {
3965
.limit(limit)
4066

4167
if (results.length === 0) {
42-
console.log('\nNo Freebuff (base2-free) messages found in this time range.')
68+
console.log(`No ${agentId} messages found in this time range.`)
69+
console.log('\nTip: Run with a different agent_id as the 4th argument, e.g.:')
70+
console.log(' bun run scripts/top-freebuff-users.ts 168 50 claude-sonnet-4-20250514')
4371
return
4472
}
4573

46-
// Print header
47-
console.log(
48-
`\n${'#'.padStart(4)} ${'Email'.padEnd(40)} ${'Messages'.padStart(10)} ${'Credits'.padStart(10)} ${'Cost'.padStart(10)} ${'Last Active'.padStart(20)}`,
49-
)
50-
console.log('─'.repeat(100))
74+
// Now run detailed queries since we have users
75+
const userIds = results.map(r => r.userId).filter((id): id is string => !!id)
76+
77+
const dailyStats = await db
78+
.select({
79+
userId: schema.message.user_id,
80+
date: sql<string>`DATE(${schema.message.finished_at})`,
81+
count: sql<number>`COUNT(*)`,
82+
})
83+
.from(schema.message)
84+
.where(
85+
sql`${schema.message.finished_at} >= ${cutoff.toISOString()}
86+
AND ${schema.message.agent_id} = ${agentId}
87+
AND ${schema.message.user_id} IN (${sql.join(userIds.map(id => sql`${id}`), sql`, `)})`,
88+
)
89+
.groupBy(sql`DATE(${schema.message.finished_at})`, schema.message.user_id)
90+
91+
const hourlyStats = await db
92+
.select({
93+
userId: schema.message.user_id,
94+
hour: sql<number>`EXTRACT(HOUR FROM ${schema.message.finished_at})`,
95+
count: sql<number>`COUNT(*)`,
96+
})
97+
.from(schema.message)
98+
.where(
99+
sql`${schema.message.finished_at} >= ${cutoff.toISOString()}
100+
AND ${schema.message.agent_id} = ${agentId}
101+
AND ${schema.message.user_id} IN (${sql.join(userIds.map(id => sql`${id}`), sql`, `)})`,
102+
)
103+
.groupBy(sql`EXTRACT(HOUR FROM ${schema.message.finished_at})`, schema.message.user_id)
104+
105+
// Aggregate daily stats per user
106+
const dailyByUser = new Map<string, { date: string; count: number }[]>()
107+
for (const d of dailyStats) {
108+
const uid = d.userId ?? ''
109+
if (!dailyByUser.has(uid)) dailyByUser.set(uid, [])
110+
dailyByUser.get(uid)!.push({ date: d.date ?? '', count: Number(d.count) })
111+
}
112+
113+
// Aggregate hourly stats per user
114+
const hourlyByUser = new Map<string, Map<number, number>>()
115+
for (const h of hourlyStats) {
116+
const hour = Number(h.hour)
117+
const uid = h.userId ?? ''
118+
if (!hourlyByUser.has(uid)) hourlyByUser.set(uid, new Map())
119+
const hourMap = hourlyByUser.get(uid)!
120+
hourMap.set(hour, (hourMap.get(hour) || 0) + Number(h.count))
121+
}
122+
123+
// Build user stats objects
124+
const userStats: UserStats[] = results.map(r => {
125+
const uid = r.userId ?? ''
126+
const daysData = dailyByUser.get(uid) || []
127+
const hourMap = hourlyByUser.get(uid) || new Map()
128+
129+
const daysActive = daysData.length
130+
const maxMessagesInDay = daysData.reduce((max, d) => Math.max(max, d.count), 0)
131+
const avgMessagesPerDay = daysData.length > 0
132+
? Math.round(daysData.reduce((sum, d) => sum + d.count, 0) / daysData.length)
133+
: 0
134+
135+
const totalTokens = Number(r.totalInputTokens) + Number(r.totalOutputTokens)
136+
const cacheReadTokens = Number(r.totalCacheReadTokens)
137+
const cacheHitRate = totalTokens > 0 ? (cacheReadTokens / totalTokens) * 100 : 0
138+
139+
return {
140+
userId: r.userId ?? 'unknown',
141+
email: r.email,
142+
messageCount: Number(r.messageCount),
143+
totalCredits: Number(r.totalCredits),
144+
totalCost: Number(r.totalCost),
145+
totalInputTokens: Number(r.totalInputTokens),
146+
totalOutputTokens: Number(r.totalOutputTokens),
147+
totalCacheReadTokens: cacheReadTokens,
148+
cacheHitRate: Math.round(cacheHitRate * 10) / 10,
149+
daysActive,
150+
avgMessagesPerDay,
151+
maxMessagesInDay,
152+
firstMessage: r.firstMessage ?? '',
153+
lastMessage: r.lastMessage ?? '',
154+
hourlyDistribution: hourMap,
155+
}
156+
})
157+
158+
// Print summary table
159+
console.log(`${'#'.padStart(3)} ${'Email'.padEnd(35)} ${'Msgs'.padStart(7)} ${'Days'.padStart(5)} ${'Avg/Day'.padStart(8)} ${'Max/Day'.padStart(8)} ${'InTok'.padStart(9)} ${'OutTok'.padStart(9)} ${'Cache%'.padStart(7)} ${'Credits'.padStart(9)}`)
160+
console.log(`${'='.repeat(105)}`)
51161

52162
let totalMessages = 0
163+
let totalCredits = 0
53164
let totalCost = 0
165+
let totalInputTokens = 0
166+
let totalOutputTokens = 0
54167

55-
for (let i = 0; i < results.length; i++) {
56-
const r = results[i]
57-
const msgCount = parseInt(r.messageCount)
58-
const cost = parseFloat(r.totalCost)
59-
const credits = parseInt(r.totalCredits)
60-
totalMessages += msgCount
61-
totalCost += cost
62-
63-
const emailDisplay = r.email
64-
? r.email.length > 38
65-
? r.email.slice(0, 35) + '...'
66-
: r.email
67-
: r.userId ?? 'unknown'
168+
for (let i = 0; i < userStats.length; i++) {
169+
const u = userStats[i]
170+
totalMessages += u.messageCount
171+
totalCredits += u.totalCredits
172+
totalCost += u.totalCost
173+
totalInputTokens += u.totalInputTokens
174+
totalOutputTokens += u.totalOutputTokens
68175

69-
const lastActive = r.lastMessage
70-
? new Date(r.lastMessage).toISOString().replace('T', ' ').slice(0, 16)
71-
: 'N/A'
176+
const emailDisplay = (u.email ?? u.userId.slice(0, 8) + '...')
177+
.slice(0, 33)
72178

73179
console.log(
74-
`${String(i + 1).padStart(4)} ${emailDisplay.padEnd(40)} ${msgCount.toLocaleString().padStart(10)} ${credits.toLocaleString().padStart(10)} ${('$' + cost.toFixed(2)).padStart(10)} ${lastActive.padStart(20)}`,
180+
`${String(i + 1).padStart(3)} ${emailDisplay.padEnd(35)} ${u.messageCount.toLocaleString().padStart(7)} ${u.daysActive.toString().padStart(5)} ${u.avgMessagesPerDay.toString().padStart(8)} ${u.maxMessagesInDay.toString().padStart(8)} ${u.totalInputTokens.toLocaleString().padStart(9)} ${u.totalOutputTokens.toLocaleString().padStart(9)} ${(u.cacheHitRate + '%').padStart(7)} ${u.totalCredits.toLocaleString().padStart(9)}`,
75181
)
76182
}
77183

78-
console.log('─'.repeat(100))
184+
console.log(`${'='.repeat(105)}`)
79185
console.log(
80-
`\nTotal: ${results.length} users, ${totalMessages.toLocaleString()} messages, $${totalCost.toFixed(2)} cost`,
186+
`\nTotal: ${userStats.length} users, ${totalMessages.toLocaleString()} messages, ${totalCredits.toLocaleString()} credits, $${totalCost.toFixed(2)}`,
81187
)
188+
console.log(`Tokens: ${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out\n`)
189+
190+
// Time distribution analysis - top 10 users by message count
191+
console.log(`${'='.repeat(100)}`)
192+
console.log(` TIME DISTRIBUTION ANALYSIS (Top 10 users)`)
193+
console.log(`${'='.repeat(100)}\n`)
194+
195+
const top10 = userStats.slice(0, 10)
196+
197+
// Aggregate hourly distribution across top users
198+
const overallHourly = new Map<number, number>()
199+
for (const u of top10) {
200+
for (const [hour, count] of u.hourlyDistribution) {
201+
overallHourly.set(hour, (overallHourly.get(hour) || 0) + count)
202+
}
203+
}
204+
205+
// Sort by hour and display
206+
const sortedHours = [...overallHourly.entries()].sort((a, b) => a[0] - b[0])
207+
const maxHourCount = Math.max(...sortedHours.map(([_, c]) => c))
208+
209+
console.log('Hourly activity distribution (all top 10 users combined):')
210+
console.log('')
211+
212+
for (const [hour, count] of sortedHours) {
213+
const bar = '='.repeat(Math.round((count / maxHourCount) * 40))
214+
const hourStr = hour.toString().padStart(2, '0') + ':00'
215+
console.log(` ${hourStr} ${count.toString().padStart(5)} ${bar}`)
216+
}
217+
218+
// Day of week analysis
219+
const dayOfWeekStats = await db
220+
.select({
221+
dayOfWeek: sql<number>`EXTRACT(DOW FROM ${schema.message.finished_at})`,
222+
count: sql<number>`COUNT(*)`,
223+
})
224+
.from(schema.message)
225+
.where(
226+
sql`${schema.message.finished_at} >= ${cutoff.toISOString()}
227+
AND ${schema.message.agent_id} = ${agentId}
228+
AND ${schema.message.user_id} IN (${sql.join(userIds.map(id => sql`${id}`), sql`, `)})`,
229+
)
230+
.groupBy(sql`EXTRACT(DOW FROM ${schema.message.finished_at})`)
82231

83-
const highUsageEmails = results
84-
.filter((r) => parseInt(r.messageCount) >= 50 && r.email)
85-
.map((r) => r.email)
232+
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
233+
console.log('\nDay of week distribution:')
234+
const sortedDays = dayOfWeekStats.sort((a, b) => Number(a.dayOfWeek) - Number(b.dayOfWeek))
235+
const maxDayCount = Math.max(...sortedDays.map(d => Number(d.count)))
236+
237+
for (const d of sortedDays) {
238+
const dayName = dayNames[Number(d.dayOfWeek)]
239+
const count = Number(d.count)
240+
const bar = '='.repeat(Math.round((count / maxDayCount) * 30))
241+
console.log(` ${dayName} ${count.toString().padStart(5)} ${bar}`)
242+
}
86243

87-
if (highUsageEmails.length > 0) {
88-
console.log(`\n── Users with ≥50 messages (${highUsageEmails.length}) ──`)
89-
console.log(highUsageEmails.join(', '))
90-
} else {
91-
console.log('\nNo users with ≥50 messages.')
244+
// Active days histogram
245+
console.log('\nDays active histogram:')
246+
const daysActiveCounts = new Map<number, number>()
247+
for (const u of userStats) {
248+
daysActiveCounts.set(u.daysActive, (daysActiveCounts.get(u.daysActive) || 0) + 1)
92249
}
250+
const sortedDaysActive = [...daysActiveCounts.entries()].sort((a, b) => a[0] - b[0])
251+
const maxActiveUsers = Math.max(...sortedDaysActive.map(([_, c]) => c))
252+
253+
for (const [days, count] of sortedDaysActive) {
254+
const bar = '='.repeat(Math.round((count / maxActiveUsers) * 40))
255+
console.log(` ${days.toString().padStart(2)} days ${count.toString().padStart(3)} users ${bar}`)
256+
}
257+
258+
// Session stats - users with highest avg messages per active day
259+
console.log('\nTop 10 users by avg messages per active day:')
260+
console.log(`${'Email'.padEnd(40)} ${'Days Active'.padStart(12)} ${'Avg/Day'.padStart(10)} ${'Max/Day'.padStart(10)}`)
261+
console.log(`${'='.repeat(75)}`)
262+
263+
const byAvgPerDay = [...userStats]
264+
.filter(u => u.daysActive > 0)
265+
.sort((a, b) => b.avgMessagesPerDay - a.avgMessagesPerDay)
266+
.slice(0, 10)
267+
268+
for (const u of byAvgPerDay) {
269+
const emailDisplay = (u.email ?? u.userId.slice(0, 8) + '...')
270+
.slice(0, 38)
271+
272+
console.log(
273+
`${emailDisplay.padEnd(40)} ${u.daysActive.toString().padStart(12)} ${u.avgMessagesPerDay.toString().padStart(10)} ${u.maxMessagesInDay.toString().padStart(10)}`,
274+
)
275+
}
276+
277+
console.log('\n')
93278
}
94279

95280
topFreebuffUsers()

0 commit comments

Comments
 (0)