@@ -2,35 +2,61 @@ import { db } from '@codebuff/internal/db'
22import * as schema from '@codebuff/internal/db/schema'
33import { 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+
523async 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
95280topFreebuffUsers ( )
0 commit comments