Detecting user exit / browser tab closed / session end

Situation : I was struggling to detect when an user exited the application (not explicit Logout, but just closed the browser tab). I implemented a heartbeat thread which will be used to monitor if the user is working on a task, if the user leaves (either by closing the tab or logging out) then return the unprocessed tasks to queue immediately. (This is different from when user goes idle, where the message can timeout and return to queue after the timeout seconds.)

Problem : There is no standard way in streamlit to tell if the browser tab is active (again not idle, but the tab is closed), which made the heartbeat thread go on forever once started with exception where there is an explicit logout triggered inside the session itself. I tried adding some variable to session_state dictionary, but for some reason it persists for a long time (infinite?) after the tab is closed and thus the heartbeat thread never stops ticking.

My solution : After much hacking and digging into source code / discussions, I came up with following solution :

def heartbeat(user_id):
    with open("writelog.log", 'a') as f:
        f.write(f"User '{user_id}' Alive at {datetime.datetime.now()}\n")

def start_beating(user_id):
    thread = threading.Timer(interval=2, function=start_beating, args=(user_id,) )
   
    # insert context to the current thread, needed for 
    # getting session specific attributes like st.session_state

    add_script_run_ctx(thread)

     # context is required to get session_id of the calling
     # thread (which would be the script thread) 
    ctx = get_script_run_ctx()     

    runtime = get_instance()     # this is the main runtime, contains all the sessions

    if runtime.is_active_session(session_id=ctx.session_id) and some_more_conditions():
        # Session is running
        thread.start()
        heartbeat(user_id)
    else:
        # Session is not running, Do what you want to do on user exit here
        return

I found two places where sessions are stored inside the SessionManager

  1. runtime._session_mgr.session_storage.list() : Contained in SessionStorage which contains the cache of sessions. I however did not understand when the sessions are inserted and removed in this list, sometimes the sessions did not exist while I was clearly in one and also it take the ‘ttl’ time (2 mins by default) to get it removed from the cache.
  2. runtime.is_active_session() or runtime._session_mgr.list_active_sessions() This is what I used above, it seems to update immediately as the user open / close tab

Questions :

  1. It is a good way to handle it ? My initial impression is that it works, however some discussion here : https://github.com/streamlit/streamlit/issues/6166 indicates that the delay in session object being deleted is to handle intermittent connection drops. Should I be using the cache storage instead or there is a better session resolver to get the actual working copy of the session object ?
  2. Is there any other recommended way to achieve this ?
  3. Are there side effects / drawbacks to my approach?

Note : I did find other threads similar to this, but none actually had a working solution, This might be a duplicated effort.

2 Likes

@potbot_88 Can you share what are the functions we need to import

1 Like

Here :

import streamlit as st
import threading 

from streamlit.runtime.scriptrunner import add_script_run_ctx
from streamlit.runtime.scriptrunner.script_run_context import get_script_run_ctx
from streamlit.runtime import get_instance
3 Likes

I am facing one problem, the session is active in the tab and the heartbeat is running but suppose i be inactive for few minutes and then it immediately executes the else block but i dont want to go the control to else block until an unless the tab is active in his chrome window, once he closes or refreshes the tab then only i want to execute the else block, how do i do that?

1 Like

I never faced such an issue, the app works well for user inactivity for infinitely (seemingly) long periods. As long as the browser is not suspending the tab or user refreshing/closing it, this mechanism should work. Also, it is not clear why heartbeat is working but the else condition was fired in your case. Can you show the exact code which you are using?

@potbot_88 Thanks for the sample code, that helped me get started on this, and your code has been really helpful, but I’m running into some issues with trying to execute things on user exit.

Specifically, I’m trying to get everything shutdown from that session, but it seems like if a function is started by that session it keeps running even after closing the session.

In the code below, I have a function that will start writing to a file when you click the button, but then if you close the window, the heartbeat function works as expected and will write the exit log file, but until I kill the Streamlit process (which requires two Ctrl+C’s to do it since it seems unable to gracefully exit with one Ctrl+C, I assume due to the do_stuff function still running) I’m still seeing writes to the doing log file indicating the do_stuff function is still running.

Using psutil, I can see the number of threads under the Streamlit process increase by one when I hit the button to start the do_stuff function, and when I close the window the thread count decreases by one, but obviously that function is still running.

Is there a better/different way to shutdown the session and everything associated with it? The point of this is to be able to kill computationally intensive stuff if the user closes the tab so that it doesn’t use up resources on the server.

This is the full code to show what I’m doing, any insights would be much appreciated.

import streamlit as st
import threading

from streamlit.runtime.scriptrunner import add_script_run_ctx
from streamlit.runtime.scriptrunner_utils.script_run_context import get_script_run_ctx
from streamlit.runtime import get_instance
import datetime
import time


def heartbeat(user_id):
    with open("writelog.log", "a") as f:
        f.write(f"User '{user_id}' Alive at {datetime.datetime.now()}\n")


def start_beating(user_id):
    thread = threading.Timer(interval=2, function=start_beating, args=(user_id,))

    # insert context to the current thread, needed for
    # getting session specific attributes like st.session_state

    add_script_run_ctx(thread)

    # context is required to get session_id of the calling
    # thread (which would be the script thread)
    ctx = get_script_run_ctx()

    runtime = get_instance()  # this is the main runtime, contains all the sessions

    if runtime.is_active_session(session_id=ctx.session_id):
        # Session is running
        thread.start()
        heartbeat(user_id)
    else:
        # Session is not running, Do what you want to do on user exit here
        with open("exitlog.log", "a") as f:
            f.write(f"Session '{ctx.session_id}' exited at {datetime.datetime.now()}\n")
        runtime.close_session(session_id=ctx.session_id)
        return


if __name__ == "__main__":
    start_beating(42)

    def do_stuff():
        ctx = get_script_run_ctx()
        for _ in range(10000):
            time.sleep(5)
            with open('doinglog.log', 'a') as f:
                f.write(f'Still running from {ctx.session_id}')

    st.title("hello world")

    st.button("run something", on_click=do_stuff)

1 Like

Heartbeat System:

import uuid
import datetime
import threading
import streamlit as st
import json

from pathlib import Path
from typing import Dict
from datetime import datetime, timedelta
from streamlit.runtime import get_instance
from streamlit.runtime.scriptrunner import add_script_run_ctx
from streamlit.runtime.scriptrunner_utils.script_run_context import get_script_run_ctx


class HeartbeatManager:
    def __init__(self, storage_file: str = "user_sessions.json"):
        self.storage_file = Path(storage_file)
        self.lock = threading.Lock()
        self._ensure_storage_file()

    def _ensure_storage_file(self):
        """Create storage file if it doesn't exist"""
        if not self.storage_file.exists():
            with open(self.storage_file, 'w') as f:
                json.dump({}, f)

    def _load_sessions(self) -> Dict:
        """Load session data from file"""
        try:
            with open(self.storage_file, 'r') as f:
                return json.load(f)
        except (json.JSONDecodeError, FileNotFoundError):
            return {}

    def _save_sessions(self, sessions: Dict):
        """Save session data to file"""
        with open(self.storage_file, 'w') as f:
            json.dump(sessions, f, indent=2)

    def update_heartbeat(self, user_id: str):
        """Update user's heartbeat timestamp"""
        current_time = datetime.now().isoformat()
        with self.lock:
            sessions = self._load_sessions()
            
            # Ensure proper data structure
            if user_id not in sessions or not isinstance(sessions[user_id], dict):
                sessions[user_id] = {
                    'heartbeat': current_time,
                    'last_activity': current_time
                }
            else:
                sessions[user_id]['heartbeat'] = current_time
            
            self._save_sessions(sessions)

    def update_activity(self, user_id: str):
        """Update user's last activity timestamp"""
        current_time = datetime.now().isoformat()
        with self.lock:
            sessions = self._load_sessions()
            
            # Ensure proper data structure
            if user_id not in sessions or not isinstance(sessions[user_id], dict):
                sessions[user_id] = {
                    'heartbeat': current_time,
                    'last_activity': current_time
                }
            else:
                sessions[user_id]['last_activity'] = current_time
            
            self._save_sessions(sessions)

    def cleanup_sessions(self, heartbeat_timeout_minutes: int = 1):
        """Remove inactive sessions based on heartbeat"""
        with self.lock:
            sessions = self._load_sessions()
            current_time = datetime.now()
            active_sessions = {}
            
            for user_id, data in sessions.items():
                # Skip invalid entries
                if not isinstance(data, dict) or 'heartbeat' not in data:
                    continue
                
                try:
                    last_heartbeat = datetime.fromisoformat(data['heartbeat'])
                    if current_time - last_heartbeat < timedelta(minutes=heartbeat_timeout_minutes):
                        active_sessions[user_id] = data
                except (ValueError, TypeError):
                    continue
            
            self._save_sessions(active_sessions)
            return active_sessions

    def get_session_info(self, user_id: str) -> Dict:
        """Get session information for a specific user"""
        with self.lock:
            sessions = self._load_sessions()
            user_data = sessions.get(user_id, {})
            if not isinstance(user_data, dict):
                return {}
            return user_data


class SessionState:
    def __init__(self):
        self.user_id = None


def get_user_id():
    """Generate or retrieve user ID from session state"""
    if 'session_state' not in st.session_state:
        st.session_state.session_state = SessionState()
        
    if st.session_state.session_state.user_id is None:
        cookies = st.context.cookies
        user_id = cookies.get('user_id')
        
        if not user_id:
            user_id = str(uuid.uuid4())
            
        st.session_state.session_state.user_id = user_id
        
    return st.session_state.session_state.user_id


def start_heartbeat(user_id: str):
    """Start the heartbeat for a user"""
    thread = threading.Timer(interval=30.0, function=start_heartbeat, args=(user_id,))
    add_script_run_ctx(thread)
    thread.daemon = True
    
    ctx = get_script_run_ctx()
    runtime = get_instance()
    
    if runtime.is_active_session(session_id=ctx.session_id):
        thread.start()
        heartbeat_manager.update_heartbeat(user_id)
    else:
        runtime.close_session(session_id=ctx.session_id)
        return


def periodic_cleanup():
    """Periodically clean up inactive sessions"""
    thread = threading.Timer(60.0, periodic_cleanup)
    add_script_run_ctx(thread)
    thread.daemon = True
    thread.start()
    
    heartbeat_manager.cleanup_sessions()


def main():
    user_id = get_user_id()
    
    # Start heartbeat if not already started
    if 'heartbeat_started' not in st.session_state:
        st.session_state.heartbeat_started = True
        start_heartbeat(user_id)
    
    # Update activity timestamp (this happens on any interaction)
    heartbeat_manager.update_activity(user_id)
    
    # Your Streamlit UI components
    st.title("Application with Heartbeat")
    
    if st.button("Do Something"):
        do_something()
    
    # Display active users and session info (for demonstration)
    active_sessions = heartbeat_manager.cleanup_sessions()
    st.write(f"Active users: {len(active_sessions)}")
    
    session_info = heartbeat_manager.get_session_info(user_id)
    if session_info:
        st.write("Your session info:")
        st.write(f"Last heartbeat: {session_info.get('heartbeat', 'Not available')}")
        st.write(f"Last activity: {session_info.get('last_activity', 'Not available')}")


def do_something():
    """Example function for button click"""
    ctx = get_script_run_ctx()
    st.write(f"Action performed by session: {ctx.session_id}")


# Create a global HeartbeatManager instance
heartbeat_manager = HeartbeatManager()


if __name__ == "__main__":
    # Start the cleanup thread
    periodic_cleanup()
    main()

Output:

{
  "450bba55-f911-49aa-b432-07da59895868": {
    "heartbeat": "2024-12-02T10:41:25.192588",
    "last_activity": "2024-12-02T10:02:24.625096"
  },
  "0c821ecf-ec7b-4120-a65c-7d9d61ce4359": {
    "heartbeat": "2024-12-02T10:41:21.056841",
    "last_activity": "2024-12-02T10:40:27.238259"
  }
}
1 Like