Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Firebase secure deployment support #30

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Streamlit
.streamlit/secrets.toml

firestore-key.json
results.json
.vscode
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion streamlit_analytics/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
39 changes: 31 additions & 8 deletions streamlit_analytics/firestore.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
82 changes: 62 additions & 20 deletions streamlit_analytics/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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
)