Files
Stat-Dashboard/mockups/metrics_api.py

115 lines
10 KiB
Python

"""Explicit UTC activity and reported-sales metric definitions."""
import statistics
def engagement(conn,game_id,days,player_ids=None):
daily=conn.execute("""
WITH calendar AS (SELECT generate_series(current_date-(%(days)s-1),current_date,interval '1 day')::date AS day),
selected_players AS (SELECT id,first_seen FROM players WHERE game_id=%(gid)s AND (%(player_ids)s::bigint[] IS NULL OR id=ANY(%(player_ids)s))),
active AS (SELECT player_id,active_day FROM player_active_days WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) AND active_day>=current_date-(%(days)s+29)),
first_sessions AS (SELECT DISTINCT ON(player_id) player_id,started_at,ended_at FROM sessions WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) ORDER BY player_id,started_at,id),
visits AS (SELECT a.active_day AS day,count(*) dau,count(*) FILTER(WHERE (p.first_seen AT TIME ZONE 'UTC')::date=a.active_day) new_users
FROM active a JOIN selected_players p ON p.id=a.player_id GROUP BY a.active_day),
times AS (SELECT active_day AS day,sum(playtime) total,count(*) observed,count(playtime) known FROM session_day_playtime
WHERE game_id=%(gid)s AND player_id IN(SELECT id FROM selected_players) AND active_day>=current_date-(%(days)s-1) GROUP BY active_day),
starts AS (SELECT (s.started_at AT TIME ZONE 'UTC')::date AS day,count(*) sessions,
avg(extract(epoch FROM ended_at-started_at))::double precision avg_session,
sum(extract(epoch FROM ended_at-started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date)::double precision new_time,
count(s.ended_at) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date=(p.first_seen AT TIME ZONE 'UTC')::date) new_count,
sum(extract(epoch FROM ended_at-started_at)) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date)::double precision returning_time,
count(s.ended_at) FILTER(WHERE (s.started_at AT TIME ZONE 'UTC')::date>(p.first_seen AT TIME ZONE 'UTC')::date) returning_count
FROM sessions s JOIN selected_players p ON p.id=s.player_id
WHERE s.game_id=%(gid)s AND s.started_at>=current_date-(%(days)s-1) GROUP BY day),
first5 AS (SELECT (started_at AT TIME ZONE 'UTC')::date AS day,count(ended_at) known,
count(*) FILTER(WHERE ended_at-started_at>=interval '5 minutes') stayed5 FROM first_sessions GROUP BY day)
SELECT c.day,coalesce(v.dau,0)::int dau,coalesce(v.new_users,0)::int new_users,
(coalesce(v.dau,0)-coalesce(v.new_users,0))::int returning_users,
(SELECT count(DISTINCT player_id)::int FROM active a WHERE a.active_day BETWEEN c.day-29 AND c.day) mau,
coalesce(s.sessions,0)::int sessions,s.avg_session,
CASE WHEN t.known>0 THEN t.total WHEN t.observed>0 THEN NULL ELSE 0 END total_playtime,
s.new_time,s.new_count,s.returning_time,s.returning_count,f.known first_known,f.stayed5 first_stayed5
FROM calendar c LEFT JOIN visits v ON v.day=c.day LEFT JOIN times t ON t.day=c.day
LEFT JOIN starts s ON s.day=c.day LEFT JOIN first5 f ON f.day=c.day ORDER BY c.day
""",{"gid":game_id,"days":days,"player_ids":player_ids}).fetchall()
for r in daily:
r['average_playtime']=r['total_playtime']/r['dau'] if r['total_playtime'] is not None and r['dau'] else None
r['new_session_time']=r['new_time']/r['new_count'] if r['new_count'] else None
r['returning_session_time']=r['returning_time']/r['returning_count'] if r['returning_count'] else None
r['first_session_5min']=100*r['first_stayed5']/r['first_known'] if r['first_known'] else None
latest=daily[-1]
new_count=sum(r['new_count'] or 0 for r in daily)
returning_count=sum(r['returning_count'] or 0 for r in daily)
first_known=sum(r['first_known'] or 0 for r in daily)
known_time=[r['total_playtime'] for r in daily if r['total_playtime'] is not None]
active_users=conn.execute("SELECT count(DISTINCT player_id) total FROM player_active_days WHERE game_id=%s AND active_day>=current_date-(%s-1) AND (%s::bigint[] IS NULL OR player_id=ANY(%s))",(game_id,days,player_ids,player_ids)).fetchone()['total']
total=sum(known_time) if known_time else None
metrics={'dau':latest['dau'],'mau':latest['mau'],'newUsersToday':latest['new_users'],'returningUsersToday':latest['returning_users'],
'totalPlaytime':total,'averagePlaytime':total/active_users if total is not None and active_users else None,
'newSessionTime':sum(r['new_time'] or 0 for r in daily)/new_count if new_count else None,
'returningSessionTime':sum(r['returning_time'] or 0 for r in daily)/returning_count if returning_count else None,
'firstSession5min':100*sum(r['first_stayed5'] or 0 for r in daily)/first_known if first_known else None,
'activeUsers':active_users}
return {'metrics':metrics,'daily':daily}
def monetization(conn,game_id,days):
totals=conn.execute("""SELECT coalesce(sum(unit_price_robux*quantity),0)::double precision revenue,
count(DISTINCT player_id) FILTER(WHERE unit_price_robux>0)::int paying_users,
coalesce(sum(quantity) FILTER(WHERE unit_price_robux>0),0)::int paid_products,
count(*) FILTER(WHERE player_id IS NULL AND unit_price_robux>0)::int unattributed_sales,
count(*)::int transactions FROM product_sales WHERE game_id=%s AND purchased_at>=current_date-(%s-1)""",(game_id,days)).fetchone()
active=conn.execute("""SELECT count(DISTINCT player_id)::int users,count(*)::int user_days FROM player_active_days
WHERE game_id=%s AND active_day>=current_date-(%s-1)""",(game_id,days)).fetchone()
valid=totals['unattributed_sales']==0
metrics={'revenue':totals['revenue'],'payingUsers':totals['paying_users'] if valid else None,
'payerConversion':100*totals['paying_users']/active['users'] if active['users'] and valid else None,
'arppu':totals['revenue']/totals['paying_users'] if totals['paying_users'] and valid else None,
'productsPerPayer':totals['paid_products']/totals['paying_users'] if totals['paying_users'] and valid else None,
'arpdau':totals['revenue']/active['user_days'] if active['user_days'] and valid else None,
'transactions':totals['transactions'],'unattributedSales':totals['unattributed_sales']}
daily=conn.execute("""WITH calendar AS(SELECT generate_series(current_date-(%(days)s-1),current_date,interval '1 day')::date AS day),
sales AS(SELECT (purchased_at AT TIME ZONE 'UTC')::date AS day,sum(unit_price_robux*quantity)::double precision robux,
count(DISTINCT player_id) FILTER(WHERE unit_price_robux>0)::int payers FROM product_sales
WHERE game_id=%(gid)s AND purchased_at>=current_date-(%(days)s-1) GROUP BY day),
active AS(SELECT active_day AS day,count(*)::int dau FROM player_active_days WHERE game_id=%(gid)s AND active_day>=current_date-(%(days)s-1) GROUP BY day)
SELECT c.day,coalesce(s.robux,0) robux,coalesce(s.payers,0) payers,coalesce(a.dau,0) dau FROM calendar c LEFT JOIN sales s ON s.day=c.day LEFT JOIN active a ON a.day=c.day ORDER BY c.day""",{'gid':game_id,'days':days}).fetchall()
for r in daily:
r['arpdau']=r['robux']/r['dau'] if r['dau'] and valid else None
r['conversion']=100*r['payers']/r['dau'] if r['dau'] and valid else None
if not valid:
r['payers']=None
hourly=conn.execute("""WITH hours AS(SELECT generate_series(date_trunc('day',now())-(%(days)s-1)*interval '1 day',date_trunc('hour',now()),interval '1 hour') AS hour),
sales AS(SELECT date_trunc('hour',purchased_at) AS hour,sum(unit_price_robux*quantity)::double precision robux FROM product_sales
WHERE game_id=%(gid)s AND purchased_at>=current_date-(%(days)s-1) GROUP BY hour)
SELECT h.hour,coalesce(s.robux,0) robux FROM hours h LEFT JOIN sales s ON s.hour=h.hour ORDER BY h.hour""",{'gid':game_id,'days':days}).fetchall()
products=conn.execute("""SELECT p.id,p.roblox_product_id,p.name,p.current_price_robux,
coalesce(sum(s.unit_price_robux*s.quantity),0)::double precision robux,
coalesce(sum(s.quantity) FILTER(WHERE s.unit_price_robux>0),0)::int quantity,
count(DISTINCT s.player_id) FILTER(WHERE s.unit_price_robux>0)::int buyers
FROM products p LEFT JOIN product_sales s ON s.game_id=p.game_id AND s.product_id=p.id AND s.purchased_at>=current_date-(%s-1)
WHERE p.game_id=%s GROUP BY p.id ORDER BY robux DESC,p.id""",(days,game_id)).fetchall()
for p in products:
p['perPayer']=p['quantity']/totals['paying_users'] if totals['paying_users'] and valid else None
return {'metrics':metrics,'daily':daily,'hourly':hourly,'products':products}
def gamepasses(conn,game_id):
totals=conn.execute("""SELECT count(*)::int players,count(*) FILTER(WHERE EXISTS(
SELECT 1 FROM product_sales s WHERE s.game_id=p.game_id AND s.player_id=p.id AND s.unit_price_robux>0))::int payers
FROM players p WHERE p.game_id=%s""",(game_id,)).fetchone()
passes=conn.execute("""WITH payers AS(SELECT DISTINCT player_id FROM product_sales WHERE game_id=%s AND unit_price_robux>0 AND player_id IS NOT NULL)
SELECT gp.id,gp.name,gp.roblox_gamepass_id,
count(o.player_id)::int checked,count(o.player_id) FILTER(WHERE o.is_owned)::int owners,
count(o.player_id) FILTER(WHERE p.player_id IS NOT NULL)::int checked_payers,
count(o.player_id) FILTER(WHERE o.is_owned AND p.player_id IS NOT NULL)::int paying_owners,
min(o.checked_at) oldest_check,max(o.checked_at) newest_check
FROM gamepasses gp LEFT JOIN gamepass_ownership o ON o.game_id=gp.game_id AND o.gamepass_id=gp.id
LEFT JOIN payers p ON p.player_id=o.player_id WHERE gp.game_id=%s GROUP BY gp.id ORDER BY gp.id""",(game_id,game_id)).fetchall()
for gp in passes:
gp['playerPercent']=100*gp['owners']/totals['players'] if totals['players'] and gp['checked']==totals['players'] else None
gp['payerPercent']=100*gp['paying_owners']/totals['payers'] if totals['payers'] and gp['checked_payers']==totals['payers'] else None
gp['coverage']=100*gp['checked']/totals['players'] if totals['players'] else None
return {'metrics':{'gamepasses':len(passes),'trackedPlayers':totals['players'],'lifetimePayers':totals['payers']},
'gamepasses':passes,'denominators':totals,
'definition':'Current reported ownership across all tracked players and lifetime positive-price paying users. Unknown ownership is not treated as not owned.'}