diff --git a/.gitignore b/.gitignore index 182af86..e2935e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Streamlit +.streamlit/secrets.toml + firestore-key.json results.json .vscode diff --git a/README.md b/README.md index 2685f08..2a44194 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,46 @@ your app (see image above). # or pass the same args to `start_tracking` AND `stop_tracking` ``` +- If you don't want to push your `firebase-key.json` to GitHub, you can do the following to securely deploy on Streamlit Cloud, or your own hosting solution. + +1. Run this code to create the streamlit secrets directory and add your firebase key to `.streamlit/secrets.toml`. (Replace `path_to_firebase_key.json` with your path) + + ```python + import toml + import os + + # Create streamlit secrets directory and secrets.toml if it doesn't exist + if not os.path.exists("./.streamlit"): + os.mkdir("./.streamlit") + f = open("./.streamlit/secrets.toml", "x") + f.close() + + output_file = ".streamlit/secrets.toml" + + with open(path_to_firebase_key.json) as json_file: + json_text = json_file.read() + + config = {"firebase": json_text} + toml_config = toml.dumps(config) + + with open(output_file, "w") as target: + target.write(toml_config) + ``` +2. Add this to the top of your file + ```python + with streamlit_analytics.track(firestore_collection_name="counts", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): + # or pass the same args to `start_tracking` AND `stop_tracking` + ``` +**Full Example** + ```python + import streamlit as st + import streamlit_analytics + + with streamlit_analytics.track(firestore_collection_name="counts", streamlit_secrets_firestore_key="firebase", firestore_project_name=firestore_project_name): + st.text_input("Write something") + st.button("Click me") + ``` + - You can **store analytics results as a json file** with: ```python diff --git a/streamlit_analytics/display.py b/streamlit_analytics/display.py index 9a8cbbb..7d983bc 100644 --- a/streamlit_analytics/display.py +++ b/streamlit_analytics/display.py @@ -111,7 +111,19 @@ def show_results(counts, reset_callback, unsafe_password=None): """, unsafe_allow_html=True, ) - st.write(counts["widgets"]) + for i in counts["widgets"].keys(): + st.markdown(f"##### `{i}` Widget Usage") + if type(counts["widgets"][i]) == dict: + st.dataframe(pd.DataFrame({ + "widget_name": i, + "selected_value": list(counts["widgets"][i].keys()), + "number_of_interactions": counts["widgets"][i].values() + }).sort_values(by="number_of_interactions", ascending=False)) + else: + st.dataframe(pd.DataFrame({ + "widget_name": i, + "number_of_interactions": counts["widgets"][i] + }, index=[0]).sort_values(by="number_of_interactions", ascending=False)) # Show button to reset analytics. st.header("Danger zone") diff --git a/streamlit_analytics/firestore.py b/streamlit_analytics/firestore.py index ef2ffac..e8a5b6e 100644 --- a/streamlit_analytics/firestore.py +++ b/streamlit_analytics/firestore.py @@ -1,13 +1,23 @@ from google.cloud import firestore +from google.oauth2 import service_account +import streamlit as st +import json -def load(counts, service_account_json, collection_name): +def load(counts, service_account_json, collection_name, streamlit_secrets_firestore_key, firestore_project_name): """Load count data from firestore into `counts`.""" - - # Retrieve data from firestore. - db = firestore.Client.from_service_account_json(service_account_json) - col = db.collection(collection_name) - firestore_counts = col.document("counts").get().to_dict() + if streamlit_secrets_firestore_key is not None: + # Following along here https://blog.streamlit.io/streamlit-firestore-continued/#part-4-securely-deploying-on-streamlit-sharing for deploying to Streamlit Cloud with Firestore + key_dict = json.loads(st.secrets[streamlit_secrets_firestore_key]) + creds = service_account.Credentials.from_service_account_info(key_dict) + db = firestore.Client( + credentials=creds, project=firestore_project_name) + col = db.collection(collection_name) + firestore_counts = col.document("counts").get().to_dict() + else: + db = firestore.Client.from_service_account_json(service_account_json) + col = db.collection(collection_name) + firestore_counts = col.document("counts").get().to_dict() # Update all fields in counts that appear in both counts and firestore_counts. if firestore_counts is not None: @@ -16,9 +26,22 @@ def load(counts, service_account_json, collection_name): counts[key] = firestore_counts[key] -def save(counts, service_account_json, collection_name): +def save(counts, service_account_json, collection_name, streamlit_secrets_firestore_key, firestore_project_name): """Save count data from `counts` to firestore.""" - db = firestore.Client.from_service_account_json(service_account_json) + if streamlit_secrets_firestore_key is not None: + # Following along here https://blog.streamlit.io/streamlit-firestore-continued/#part-4-securely-deploying-on-streamlit-sharing for deploying to Streamlit Cloud with Firestore + key_dict = json.loads(st.secrets[streamlit_secrets_firestore_key]) + creds = service_account.Credentials.from_service_account_info(key_dict) + db = firestore.Client( + credentials=creds, project=firestore_project_name) + else: + db = firestore.Client.from_service_account_json(service_account_json) + col = db.collection(collection_name) doc = col.document("counts") + # Make sure the keys of nested dictionaries are str type + for subdict in counts["widgets"]: + if type(counts["widgets"][subdict]) == dict: + counts["widgets"][subdict] = { + str(k): v for k, v in counts["widgets"][subdict].items()} doc.set(counts) # creates if doesn't exist diff --git a/streamlit_analytics/main.py b/streamlit_analytics/main.py index 442b5ec..84df938 100644 --- a/streamlit_analytics/main.py +++ b/streamlit_analytics/main.py @@ -24,9 +24,11 @@ def reset_counts(): counts["total_pageviews"] = 0 counts["total_script_runs"] = 0 counts["total_time_seconds"] = 0 - counts["per_day"] = {"days": [str(yesterday)], "pageviews": [0], "script_runs": [0]} + counts["per_day"] = {"days": [str(yesterday)], "pageviews": [ + 0], "script_runs": [0]} counts["widgets"] = {} - counts["start_time"] = datetime.datetime.now().strftime("%d %b %Y, %H:%M:%S") + counts["start_time"] = datetime.datetime.now().strftime( + "%d %b %Y, %H:%M:%S") reset_counts() @@ -75,7 +77,8 @@ def _track_user(): counts["total_script_runs"] += 1 counts["per_day"]["script_runs"][-1] += 1 now = datetime.datetime.now() - counts["total_time_seconds"] += (now - st.session_state.last_time).total_seconds() + counts["total_time_seconds"] += (now - + st.session_state.last_time).total_seconds() st.session_state.last_time = now if not st.session_state.user_tracked: st.session_state.user_tracked = True @@ -231,6 +234,8 @@ def start_tracking( firestore_key_file: str = None, firestore_collection_name: str = "counts", load_from_json: Union[str, Path] = None, + streamlit_secrets_firestore_key: str = None, + firestore_project_name: str = None ): """ Start tracking user inputs to a streamlit app. @@ -241,7 +246,16 @@ def start_tracking( `with streamlit_analytics.track():`. """ - if firestore_key_file and not counts["loaded_from_firestore"]: + if streamlit_secrets_firestore_key is not None and not counts["loaded_from_firestore"]: + firestore.load(counts=counts, service_account_json=None, collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, firestore_project_name=firestore_project_name) + counts["loaded_from_firestore"] = True + if verbose: + print("Loaded count data from firestore:") + print(counts) + print() + + elif firestore_key_file and not counts["loaded_from_firestore"]: firestore.load(counts, firestore_key_file, firestore_collection_name) counts["loaded_from_firestore"] = True if verbose: @@ -334,6 +348,8 @@ def stop_tracking( firestore_key_file: str = None, firestore_collection_name: str = "counts", verbose: bool = False, + streamlit_secrets_firestore_key: str = None, + firestore_project_name: str = None ): """ Stop tracking user inputs to a streamlit app. @@ -383,7 +399,15 @@ def stop_tracking( # Save count data to firestore. # TODO: Maybe don't save on every iteration but on regular intervals in a background # thread. - if firestore_key_file: + if streamlit_secrets_firestore_key is not None and firestore_project_name is not None: + if verbose: + print("Saving count data to firestore:") + print(counts) + print() + firestore.save(counts=counts, service_account_json=None, collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, firestore_project_name=firestore_project_name) + + elif streamlit_secrets_firestore_key is None and firestore_project_name is None and firestore_key_file: if verbose: print("Saving count data to firestore:") print(counts) @@ -392,7 +416,7 @@ def stop_tracking( # Dump the counts to json file if `save_to_json` is set. # TODO: Make sure this is not locked if writing from multiple threads. - if save_to_json is not None: + elif streamlit_secrets_firestore_key is None and firestore_project_name is None and save_to_json is not None: with Path(save_to_json).open("w") as f: json.dump(counts, f) if verbose: @@ -413,6 +437,8 @@ def track( firestore_collection_name: str = "counts", verbose=False, load_from_json: Union[str, Path] = None, + streamlit_secrets_firestore_key: str = None, + firestore_project_name: str = None ): """ Context manager to start and stop tracking user inputs to a streamlit app. @@ -421,21 +447,37 @@ def track( This also shows the analytics results below your app if you attach `?analytics=on` to the URL. """ - - start_tracking( - verbose=verbose, - firestore_key_file=firestore_key_file, - firestore_collection_name=firestore_collection_name, - load_from_json=load_from_json, - ) + if streamlit_secrets_firestore_key is not None and firestore_project_name is not None: + start_tracking( + verbose=verbose, + firestore_collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, + firestore_project_name=firestore_project_name + ) + + else: + start_tracking( + verbose=verbose, + firestore_key_file=firestore_key_file, + firestore_collection_name=firestore_collection_name, + load_from_json=load_from_json, + ) # Yield here to execute the code in the with statement. This will call the wrappers # above, which track all inputs. yield - stop_tracking( - unsafe_password=unsafe_password, - save_to_json=save_to_json, - firestore_key_file=firestore_key_file, - firestore_collection_name=firestore_collection_name, - verbose=verbose, - ) + if streamlit_secrets_firestore_key is not None and firestore_project_name is not None: + stop_tracking( + firestore_collection_name=firestore_collection_name, + streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, + firestore_project_name=firestore_project_name, + verbose=verbose + ) + else: + stop_tracking( + unsafe_password=unsafe_password, + save_to_json=save_to_json, + firestore_key_file=firestore_key_file, + firestore_collection_name=firestore_collection_name, + verbose=verbose + )