Skip to content

Commit cd2716c

Browse files
committed
Better quiet gap in bot report
1 parent eeba1c6 commit cd2716c

1 file changed

Lines changed: 39 additions & 13 deletions

File tree

web/src/server/free-session/abuse-detection.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -141,28 +141,54 @@ export async function identifyBotSuspects(params: {
141141
agentDiversity.map((a) => [a.user_id!, Number(a.distinctAgents24h)]),
142142
)
143143

144-
// Max inter-message quiet gap in the 24h window (in hours). A gap ≥ 4h is
145-
// a strong "user slept" counter-signal — bots don't take circadian breaks.
146-
// Uses LAG() so it needs a CTE; run as raw SQL.
144+
// Largest gap of usage (in hours) within the observation window — where
145+
// the window is bounded by GREATEST(user.created_at, now - 24h). For each
146+
// user we consider three kinds of gap: window_start → first msg, gaps
147+
// between consecutive msgs, and last msg → now. Max of those is the
148+
// quiet gap.
149+
//
150+
// Clipping the window to signup matters: a 0.2d-old account can only
151+
// plausibly have a gap up to its age. Without the clip, LAG() on an empty
152+
// pre-window history would silently omit any leading-boundary gap, so a
153+
// fresh bot with dense activity reads as "low quiet gap" correctly — but
154+
// for heavy accounts that only started hitting us within the last few
155+
// hours, we also want to count post-activity quiet time toward the gap.
156+
const nowIso = now.toISOString()
147157
const quietGaps = await db.execute(sql`
148-
WITH ordered AS (
149-
SELECT user_id, finished_at,
150-
LAG(finished_at) OVER (PARTITION BY user_id ORDER BY finished_at) AS prev
151-
FROM ${schema.message}
152-
WHERE user_id IN (${sql.join(
158+
WITH bounds AS (
159+
SELECT id AS user_id,
160+
GREATEST(created_at, ${cutoffIso}::timestamptz) AS window_start
161+
FROM ${schema.user}
162+
WHERE id IN (${sql.join(
153163
userIds.map((id) => sql`${id}`),
154164
sql`, `,
155165
)})
156-
AND agent_id IN (${sql.join(
166+
),
167+
msgs AS (
168+
SELECT m.user_id, m.finished_at, b.window_start
169+
FROM ${schema.message} m
170+
JOIN bounds b ON b.user_id = m.user_id
171+
WHERE m.finished_at >= b.window_start
172+
AND m.agent_id IN (${sql.join(
157173
FREEBUFF_ROOT_AGENT_IDS.map((a) => sql`${a}`),
158174
sql`, `,
159175
)})
160-
AND finished_at >= ${cutoffIso}::timestamptz
176+
),
177+
gaps AS (
178+
SELECT user_id,
179+
finished_at,
180+
COALESCE(
181+
LAG(finished_at) OVER (PARTITION BY user_id ORDER BY finished_at),
182+
window_start
183+
) AS prev
184+
FROM msgs
161185
)
162186
SELECT user_id,
163-
MAX(EXTRACT(EPOCH FROM (finished_at - prev))) / 3600.0 AS max_gap_hours
164-
FROM ordered
165-
WHERE prev IS NOT NULL
187+
GREATEST(
188+
MAX(EXTRACT(EPOCH FROM (finished_at - prev)) / 3600.0),
189+
EXTRACT(EPOCH FROM (${nowIso}::timestamptz - MAX(finished_at))) / 3600.0
190+
) AS max_gap_hours
191+
FROM gaps
166192
GROUP BY user_id
167193
`)
168194
const quietGapByUser = new Map<string, number>()

0 commit comments

Comments
 (0)