Search code examples
streamlitpython-logging

How to log user activity in a streamlit app?


I have a streamlit app that is public (ie. no user log-in). I would like to have log files of the form:

|2023-02-10 16:30:16 : user at ip=___ clicked button key=___
|2023-02-10 16:30:19 : user at ip=___ clicked button key=___
|2023-02-10 16:31:10 : user at ip=___ clicked button key=___
|2023-02-10 16:31:27 : user at ip=___ clicked button key=___
|...

Is there any way to achieve this? It's because I want to do some analytics on how the app is being used.


Solution

  • You can access the remote ip address via get_script_run_ctx and .remote_ip:

    from streamlit import runtime
    from streamlit.runtime.scriptrunner import get_script_run_ctx
    
    
    def get_remote_ip() -> str:
        """Get remote ip."""
    
        try:
            ctx = get_script_run_ctx()
            if ctx is None:
                return None
    
            session_info = runtime.get_instance().get_client(ctx.session_id)
            if session_info is None:
                return None
        except Exception as e:
            return None
    
        return session_info.request.remote_ip
    
    
    import streamlit as st
    
    st.title("Title")
    st.markdown(f"The remote ip is {get_remote_ip()}")
    

    For the logging part, I suggest you use a ContextFilter:

    import logging
    
    class ContextFilter(logging.Filter):
        def filter(self, record):
            record.user_ip = get_remote_ip()
            return super().filter(record)
    

    This custom filter will modify the LogRecord and add it the custom attribute user_ip that you can then use inside the Formatter.

    All together, it gives:

    import logging
    
    import streamlit as st
    from streamlit import runtime
    from streamlit.runtime.scriptrunner import get_script_run_ctx
    
    def get_remote_ip() -> str:
        """Get remote ip."""
    
        try:
            ctx = get_script_run_ctx()
            if ctx is None:
                return None
    
            session_info = runtime.get_instance().get_client(ctx.session_id)
            if session_info is None:
                return None
        except Exception as e:
            return None
    
        return session_info.request.remote_ip
    
    class ContextFilter(logging.Filter):
        def filter(self, record):
            record.user_ip = get_remote_ip()
            return super().filter(record)
    
    def init_logging():
        # Make sure to instanciate the logger only once
        # otherwise, it will create a StreamHandler at every run
        # and duplicate the messages
    
        # create a custom logger
        logger = logging.getLogger("foobar")
        if logger.handlers:  # logger is already setup, don't setup again
            return
        logger.propagate = False
        logger.setLevel(logging.INFO)
        # in the formatter, use the variable "user_ip"
        formatter = logging.Formatter("%(name)s %(asctime)s %(levelname)s [user_ip=%(user_ip)s] - %(message)s")
        handler = logging.StreamHandler()
        handler.setLevel(logging.INFO)
        handler.addFilter(ContextFilter())
        handler.setFormatter(formatter)
        logger.addHandler(handler)
    
    def main():
        logger.info("Inside main")
        st.title("Title")
    
        text = st.sidebar.text_input("Text:")
        logger.info(f"This is the text: {text}")
    
    if __name__ == "__main__":
        init_logging()
    
        logger = logging.getLogger("foobar")
        main()
    
    foobar 2023-02-13 15:43:57,252 INFO [user_ip=::1] - Inside main
    foobar 2023-02-13 15:43:57,253 INFO [user_ip=::1] - This is the text: Hello, world!
    

    Note: Here the user_ip is "::1" because everything was done locally.