feat(studio): pause unified logs live mode when nobody is watching

Live mode polled every 10 seconds for as long as the page stayed open,
including in hidden tabs and tabs left unattended for hours. Move the
polling loop into a useLivePolling hook that:

- stops polling while the tab is hidden and polls immediately when it is
  visible again
- pauses live mode after 15 minutes of visible time with no pointer, key,
  wheel, or scroll activity, with a notice to resume it
- backs off from 10s to 20s, 40s, then 60s while polls find no new rows,
  resetting on new rows, activity, or the tab becoming visible

Track clicks on the notice's "Resume live mode" button
(unified_logs_live_mode_resume_button_clicked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David WhittingtonandClaude Opus 5.5 committed 2026-09-30 12:02:58 -05:00
1 parent a756fd36ef
commit 3bf710eae9
6 files changed
+452 -20

No files matched your search

@@ -2,14 +2,17 @@ import type { FetchPreviousPageOptions } from '@tanstack/react-query'
import { CirclePause, CirclePlay } from 'lucide-react'
import { useQueryStates } from 'nuqs'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { Button } from 'ui'
import { useDataTable } from './providers/DataTableProvider'
import { useLivePolling } from './useLivePolling'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useTrack } from '@/lib/telemetry/track'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
const REFRESH_INTERVAL = 10_000
const LIVE_MODE_PAUSED_TOAST_ID = 'live-mode-paused-for-inactivity'
interface LiveButtonProps {
searchParamsParser: any
@@ -19,26 +22,20 @@ interface LiveButtonProps {
export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButtonProps) {
const [{ live, date, sort }, setSearch] = useQueryStates(searchParamsParser)
const { table } = useDataTable()
const track = useTrack()
useShortcut(SHORTCUT_IDS.DATA_TABLE_TOGGLE_LIVE, handleClick, { registerInCommandMenu: false })
useLivePolling({
isEnabled: Boolean(live),
poll: () => fetchPreviousPage?.() ?? Promise.resolve(undefined),
onIdle: handleIdle,
})
// The paused notice only makes sense while live mode is off, and only on this page.
useEffect(() => {
let timeoutId: NodeJS.Timeout
async function fetchData() {
if (live) {
await fetchPreviousPage?.()
timeoutId = setTimeout(fetchData, REFRESH_INTERVAL)
} else {
clearTimeout(timeoutId)
}
}
fetchData()
return () => {
clearTimeout(timeoutId)
}
}, [live, fetchPreviousPage])
if (live) toast.dismiss(LIVE_MODE_PAUSED_TOAST_ID)
}, [live])
useEffect(() => () => void toast.dismiss(LIVE_MODE_PAUSED_TOAST_ID), [])
// REMINDER: make sure to reset live when date is set
// TODO: test properly
@@ -48,10 +45,11 @@ export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButton
}
}, [date, sort])
function handleClick() {
// Live mode always follows the newest logs, so turning it on or off clears any date range and sort.
function setLive(getLive: (wasLive: boolean) => boolean) {
setSearch((prev) => ({
...prev,
live: !prev.live,
live: getLive(Boolean(prev.live)),
date: null,
sort: null,
}))
@@ -59,6 +57,24 @@ export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButton
table.resetSorting()
}
function handleClick() {
setLive((wasLive) => !wasLive)
}
function handleIdle() {
setSearch((prev) => ({ ...prev, live: null }))
toast('Live mode paused after 15 minutes of inactivity', {
id: LIVE_MODE_PAUSED_TOAST_ID,
duration: Infinity,
action: { label: 'Resume live mode', onClick: handleResume },
})
}
function handleResume() {
track('unified_logs_live_mode_resume_button_clicked', { pauseReason: 'inactivity' })
setLive(() => true)
}
return (
<ShortcutTooltip
shortcutId={SHORTCUT_IDS.DATA_TABLE_TOGGLE_LIVE}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import {
getLivePollDelay,
isEmptyLivePoll,
LIVE_POLL_BASE_INTERVAL_MS,
LIVE_POLL_MAX_INTERVAL_MS,
} from './LiveButton.utils'
describe('getLivePollDelay', () => {
it('polls at the base interval when the last poll found rows', () => {
expect(getLivePollDelay(0)).toBe(LIVE_POLL_BASE_INTERVAL_MS)
})
it('doubles the delay after each empty poll', () => {
expect(getLivePollDelay(1)).toBe(20_000)
expect(getLivePollDelay(2)).toBe(40_000)
})
it('caps the delay at the maximum interval', () => {
expect(getLivePollDelay(3)).toBe(LIVE_POLL_MAX_INTERVAL_MS)
expect(getLivePollDelay(50)).toBe(LIVE_POLL_MAX_INTERVAL_MS)
})
it('falls back to the base interval for negative or invalid counts', () => {
for (const count of [-1, NaN, Infinity]) {
expect(getLivePollDelay(count)).toBe(LIVE_POLL_BASE_INTERVAL_MS)
}
})
it('rounds fractional counts down', () => {
expect(getLivePollDelay(1.9)).toBe(20_000)
})
})
describe('isEmptyLivePoll', () => {
const pollResult = (pages: unknown) => ({ data: { pages } })
it('is true when the newest page has no rows', () => {
expect(isEmptyLivePoll(pollResult([{ data: [] }, { data: [{ id: 'a' }] }]))).toBe(true)
})
it('is false when the newest page has rows', () => {
expect(isEmptyLivePoll(pollResult([{ data: [{ id: 'b' }] }, { data: [] }]))).toBe(false)
})
it('is false for missing or malformed results', () => {
for (const result of [
undefined,
null,
{},
{ data: null },
pollResult(undefined),
pollResult([]),
pollResult([null]),
pollResult([{ data: 'rows' }]),
]) {
expect(isEmptyLivePoll(result)).toBe(false)
}
})
})
@@ -0,0 +1,34 @@
export const LIVE_POLL_BASE_INTERVAL_MS = 10_000
export const LIVE_POLL_MAX_INTERVAL_MS = 60_000
export const LIVE_MODE_IDLE_TIMEOUT_MS = 15 * 60_000
/**
* Delay before the next live-mode poll. Doubles after each poll that finds no new rows
* (10s, 20s, 40s), capped at 60s, so quiet projects are polled less often.
*/
export function getLivePollDelay(consecutiveEmptyPolls: number): number {
if (!Number.isFinite(consecutiveEmptyPolls) || consecutiveEmptyPolls <= 0) {
return LIVE_POLL_BASE_INTERVAL_MS
}
return Math.min(
LIVE_POLL_BASE_INTERVAL_MS * 2 ** Math.floor(consecutiveEmptyPolls),
LIVE_POLL_MAX_INTERVAL_MS
)
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
/**
* Whether a live-mode poll found no new rows. A poll is `fetchPreviousPage`, which prepends
* the new page, so its result's first page holds the rows that poll returned. Anything
* unrecognized counts as not empty, so polling never slows down on unexpected data.
*/
export function isEmptyLivePoll(result: unknown): boolean {
if (!isRecord(result) || !isRecord(result.data)) return false
const pages = result.data.pages
if (!Array.isArray(pages) || pages.length === 0) return false
const newestPage = pages[0]
if (!isRecord(newestPage) || !Array.isArray(newestPage.data)) return false
return newestPage.data.length === 0
}
@@ -0,0 +1,183 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LIVE_MODE_IDLE_TIMEOUT_MS } from './LiveButton.utils'
import { useLivePolling } from './useLivePolling'
const SECOND = 1_000
const MINUTE = 60 * SECOND
const pageResult = (rows: unknown[]) => ({ data: { pages: [{ data: rows }] } })
const NEW_ROWS = pageResult([{ id: 'a' }])
const NO_ROWS = pageResult([])
let visibilityState: DocumentVisibilityState = 'visible'
const setVisibility = async (state: DocumentVisibilityState) => {
visibilityState = state
await act(async () => {
document.dispatchEvent(new Event('visibilitychange'))
})
}
const advance = async (ms: number) => {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms)
})
}
const interact = async () => {
await act(async () => {
window.dispatchEvent(new Event('pointerdown'))
})
}
const setup = ({ result = NEW_ROWS, isEnabled = true } = {}) => {
const poll = vi.fn(() => Promise.resolve(result))
const onIdle = vi.fn()
const hook = renderHook(
(props: { isEnabled: boolean }) => useLivePolling({ isEnabled: props.isEnabled, poll, onIdle }),
{ initialProps: { isEnabled } }
)
return { poll, onIdle, hook }
}
describe('useLivePolling', () => {
beforeEach(() => {
vi.useFakeTimers()
visibilityState = 'visible'
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('polls immediately, then every 10 seconds while polls find rows', async () => {
const { poll } = setup()
await advance(0)
expect(poll).toHaveBeenCalledTimes(1)
await advance(9 * SECOND)
expect(poll).toHaveBeenCalledTimes(1)
await advance(1 * SECOND)
expect(poll).toHaveBeenCalledTimes(2)
await advance(10 * SECOND)
expect(poll).toHaveBeenCalledTimes(3)
})
it('does not poll while disabled, and stops when disabled', async () => {
const { poll, hook } = setup({ isEnabled: false })
await advance(MINUTE)
expect(poll).not.toHaveBeenCalled()
hook.rerender({ isEnabled: true })
await advance(0)
expect(poll).toHaveBeenCalledTimes(1)
hook.rerender({ isEnabled: false })
await advance(5 * MINUTE)
expect(poll).toHaveBeenCalledTimes(1)
})
it('backs off to 20s, 40s, then 60s while polls find no new rows', async () => {
const { poll } = setup({ result: NO_ROWS })
await advance(0) // poll 1 at 0s
await advance(20 * SECOND) // poll 2 at 20s
expect(poll).toHaveBeenCalledTimes(2)
await advance(39 * SECOND)
expect(poll).toHaveBeenCalledTimes(2)
await advance(1 * SECOND) // poll 3 at 60s
expect(poll).toHaveBeenCalledTimes(3)
await advance(60 * SECOND) // poll 4 at 120s
await advance(60 * SECOND) // poll 5 at 180s (capped)
expect(poll).toHaveBeenCalledTimes(5)
})
it('returns to the base interval once a poll finds rows again', async () => {
const { poll } = setup({ result: NO_ROWS })
await advance(0)
await advance(20 * SECOND) // second empty poll; next delay 40s
poll.mockResolvedValue(NEW_ROWS)
await advance(40 * SECOND) // this poll finds rows
expect(poll).toHaveBeenCalledTimes(3)
await advance(10 * SECOND)
expect(poll).toHaveBeenCalledTimes(4)
})
it('resets the backoff when the user interacts with the page', async () => {
const { poll } = setup({ result: NO_ROWS })
await advance(0)
await advance(20 * SECOND)
await advance(40 * SECOND) // three empty polls; next delay 60s
expect(poll).toHaveBeenCalledTimes(3)
await interact()
await advance(10 * SECOND)
expect(poll).toHaveBeenCalledTimes(4)
})
it('stops polling while the tab is hidden and polls immediately when it is visible again', async () => {
const { poll } = setup()
await advance(0)
expect(poll).toHaveBeenCalledTimes(1)
await setVisibility('hidden')
await advance(5 * MINUTE)
expect(poll).toHaveBeenCalledTimes(1)
await setVisibility('visible')
await advance(0)
expect(poll).toHaveBeenCalledTimes(2)
await advance(10 * SECOND)
expect(poll).toHaveBeenCalledTimes(3)
})
it('does not start polling when enabled in a hidden tab', async () => {
visibilityState = 'hidden'
const { poll } = setup()
await advance(MINUTE)
expect(poll).not.toHaveBeenCalled()
})
it('calls onIdle after 15 minutes without activity', async () => {
const { onIdle } = setup()
await advance(LIVE_MODE_IDLE_TIMEOUT_MS - SECOND)
expect(onIdle).not.toHaveBeenCalled()
await advance(SECOND)
expect(onIdle).toHaveBeenCalledTimes(1)
})
it('restarts the idle timeout on user activity', async () => {
const { onIdle } = setup()
await advance(10 * MINUTE)
await interact()
await advance(14 * MINUTE)
expect(onIdle).not.toHaveBeenCalled()
await advance(MINUTE)
expect(onIdle).toHaveBeenCalledTimes(1)
})
it('does not count hidden time toward the idle timeout', async () => {
const { onIdle } = setup()
await advance(10 * MINUTE)
await setVisibility('hidden')
await advance(30 * MINUTE)
expect(onIdle).not.toHaveBeenCalled()
await setVisibility('visible')
await advance(LIVE_MODE_IDLE_TIMEOUT_MS - SECOND)
expect(onIdle).not.toHaveBeenCalled()
await advance(SECOND)
expect(onIdle).toHaveBeenCalledTimes(1)
})
it('keeps polling after a failed poll', async () => {
const { poll } = setup()
poll.mockRejectedValueOnce(new Error('network'))
await advance(0)
await advance(10 * SECOND)
expect(poll).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,120 @@
import { useEffect, useRef } from 'react'
import {
getLivePollDelay,
isEmptyLivePoll,
LIVE_MODE_IDLE_TIMEOUT_MS,
LIVE_POLL_BASE_INTERVAL_MS,
} from './LiveButton.utils'
const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll'] as const
const ACTIVITY_THROTTLE_MS = 1_000
interface UseLivePollingOptions {
isEnabled: boolean
/** Fetches new rows. Its resolved value is inspected to back off when nothing new arrives. */
poll: () => Promise<unknown>
/** Called once the page has been visible with no user activity for the idle timeout. */
onIdle: () => void
}
/**
* Runs live-mode polling while `isEnabled`:
* - Polls every 10s, backing off to 60s while polls find no new rows.
* - Stops while the tab is hidden, and polls immediately when it becomes visible again.
* - Calls `onIdle` after 15 minutes of visible time without pointer, key, wheel, or scroll activity.
* Activity and becoming visible reset the backoff.
*/
export function useLivePolling({ isEnabled, poll, onIdle }: UseLivePollingOptions) {
// Latest callbacks, so a new function identity doesn't restart the polling loop.
const pollRef = useRef(poll)
const onIdleRef = useRef(onIdle)
useEffect(() => {
pollRef.current = poll
onIdleRef.current = onIdle
}, [poll, onIdle])
useEffect(() => {
if (!isEnabled) return
let isStopped = false
let isPolling = false
let consecutiveEmptyPolls = 0
let pollTimeoutId: ReturnType<typeof setTimeout> | undefined
let idleTimeoutId: ReturnType<typeof setTimeout> | undefined
let lastActivityAt = 0
const isHidden = () => document.visibilityState === 'hidden'
const schedulePoll = (delay: number) => {
clearTimeout(pollTimeoutId)
pollTimeoutId = setTimeout(runPoll, delay)
}
async function runPoll() {
if (isStopped || isHidden() || isPolling) return
isPolling = true
try {
const result = await pollRef.current()
consecutiveEmptyPolls = isEmptyLivePoll(result) ? consecutiveEmptyPolls + 1 : 0
} catch {
// A failed poll shouldn't end live mode; retry at the current backoff.
} finally {
isPolling = false
}
if (!isStopped && !isHidden()) schedulePoll(getLivePollDelay(consecutiveEmptyPolls))
}
const startIdleTimer = () => {
clearTimeout(idleTimeoutId)
idleTimeoutId = setTimeout(() => {
if (!isStopped) onIdleRef.current()
}, LIVE_MODE_IDLE_TIMEOUT_MS)
}
const handleActivity = () => {
const now = Date.now()
if (now - lastActivityAt < ACTIVITY_THROTTLE_MS) return
lastActivityAt = now
startIdleTimer()
// Someone is watching again: return to the base interval instead of waiting out a backoff.
if (consecutiveEmptyPolls > 0) {
consecutiveEmptyPolls = 0
if (!isPolling && !isHidden()) schedulePoll(LIVE_POLL_BASE_INTERVAL_MS)
}
}
const handleVisibilityChange = () => {
if (isHidden()) {
// Hidden tabs neither poll nor count toward the idle timeout.
clearTimeout(pollTimeoutId)
clearTimeout(idleTimeoutId)
return
}
consecutiveEmptyPolls = 0
startIdleTimer()
clearTimeout(pollTimeoutId)
void runPoll()
}
document.addEventListener('visibilitychange', handleVisibilityChange)
for (const event of ACTIVITY_EVENTS) {
window.addEventListener(event, handleActivity, { passive: true, capture: true })
}
if (!isHidden()) {
startIdleTimer()
void runPoll()
}
return () => {
isStopped = true
clearTimeout(pollTimeoutId)
clearTimeout(idleTimeoutId)
document.removeEventListener('visibilitychange', handleVisibilityChange)
for (const event of ACTIVITY_EVENTS) {
window.removeEventListener(event, handleActivity, { capture: true })
}
}
}, [isEnabled])
}
+18
View File
@@ -3817,6 +3817,23 @@ export interface ResourceExhaustionBannerTroubleshootClickedEvent {
}
}
/**
* User clicked "Resume live mode" in the notice shown after Unified Logs paused live mode
* because the page was visible with no user activity for 15 minutes.
*
* @group Events
* @source studio
* @page /dashboard/project/{ref}/logs
*/
export interface UnifiedLogsLiveModeResumeButtonClickedEvent {
action: 'unified_logs_live_mode_resume_button_clicked'
properties: {
/** Why live mode was paused before the user resumed it. */
pauseReason: 'inactivity'
}
groups: TelemetryGroups
}
/**
* User clicked a row in the Unified Logs interface.
*
@@ -4266,6 +4283,7 @@ export type TelemetryEvent =
| ResourceExhaustionBannerAiAssistantClickedEvent
| ResourceExhaustionBannerTroubleshootClickedEvent
| UnifiedLogsRowClickedEvent
| UnifiedLogsLiveModeResumeButtonClickedEvent
| HeaderHomeLogoClickedEvent
| HeaderBackToDashboardClickedEvent
| HeaderExceedingUsageBadgeClickedEvent