├── .github ├── CODEOWNERS ├── SUPPORT.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── CONTRIBUTING.md ├── workflows │ └── release.yml ├── SECURITY.md ├── README.md └── CODE_OF_CONDUCT.md ├── examples ├── pages │ ├── sa2_data.json │ ├── create_key.py │ ├── firebase_test.py │ ├── Streamlit_Analytics_Demo.py │ └── all-features.py ├── .streamlit │ └── analytics.toml └── minimal.py ├── tests ├── pytest.ini ├── test_utils.py └── run_checks.sh ├── .flake8 ├── mypy.ini ├── src └── streamlit_analytics2 │ ├── __init__.py │ ├── state.py │ ├── utils.py │ ├── firestore.py │ ├── display.py │ ├── config.py │ ├── widgets.py │ ├── wrappers.py │ └── main.py ├── analytics.toml ├── LICENSE ├── .devcontainer └── devcontainer.json ├── sa2_data.json ├── pyproject.toml └── .gitignore /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @444B -------------------------------------------------------------------------------- /examples/pages/sa2_data.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_paths = src -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W503,E501 3 | per-file-ignores = 4 | *__init__.py:F401 5 | max-line-length = 80 -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.10 3 | warn_return_any = True 4 | warn_unused_configs = True 5 | ignore_missing_imports = False 6 | 7 | [mypy-google.cloud.*] 8 | ignore_missing_imports = True 9 | 10 | [mypy-google.oauth2.*] 11 | ignore_missing_imports = True 12 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | For support in any issues arising from the use of streanlit-analytics2, kindly refer to the [Community Discussions](https://github.com/444B/streamlit-analytics2/discussions) portal 2 | 3 | Please also [create an issue](https://github.com/444B/streamlit-analytics2/issues/new/choose) or search for an existing one and sharing your voice/experience -------------------------------------------------------------------------------- /src/streamlit_analytics2/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Streamlit Analytics 2 3 | Track & visualize user interactions with your streamlit app. 4 | """ 5 | 6 | from .main import start_tracking, stop_tracking, track # noqa: F401 7 | from .state import data # noqa: F401 8 | 9 | from .state import data as counts # noqa: F401 # isort:skip 10 | 11 | __version__ = "0.10.5" 12 | __name__ = "streamlit_analytics2" 13 | -------------------------------------------------------------------------------- /analytics.toml: -------------------------------------------------------------------------------- 1 | [streamlit_analytics2] 2 | enabled = "true" 3 | 4 | [storage] 5 | save = "false" 6 | type = "json" 7 | save_to_json = "path/to/file.json" 8 | load_from_json = "path/to/file.json" 9 | 10 | [logs] 11 | verbose = "false" 12 | 13 | [access] 14 | unsafe_password = "test123" 15 | 16 | [firestore] 17 | enabled = "false" 18 | firestore_key_file = "firebase-key.json" 19 | firestore_project_name= "" 20 | firestore_collection_name = "data" 21 | streamlit_secrets_firestore_key = "" 22 | 23 | [session] 24 | session_id = "" 25 | -------------------------------------------------------------------------------- /examples/.streamlit/analytics.toml: -------------------------------------------------------------------------------- 1 | [streamlit_analytics2] 2 | enabled = true 3 | 4 | [storage] 5 | save = false 6 | type = "json" 7 | save_to_json = "path/to/file.json" 8 | load_from_json = "path/to/file.json" 9 | 10 | [logs] 11 | verbose = false 12 | 13 | [access] 14 | unsafe_password = "hunter2" 15 | 16 | [firestore] 17 | enabled = false 18 | firestore_key_file = "firebase-key.json" 19 | firestore_project_name = "" 20 | firestore_collection_name = "data" 21 | streamlit_secrets_firestore_key = "" 22 | 23 | [session] 24 | session_id = "" 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] " 5 | labels: enhancement 6 | assignees: '444B' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/streamlit_analytics2/state.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | # Dict that holds all analytics results. Note that this is persistent across 4 | # users, as modules are only imported once by a streamlit app. 5 | data = {"loaded_from_firestore": False} 6 | 7 | 8 | def reset_data(): 9 | # Use yesterday as first entry to make chart look better. 10 | yesterday = str(datetime.date.today() - datetime.timedelta(days=1)) 11 | data["total_pageviews"] = 0 12 | data["total_script_runs"] = 0 13 | data["total_time_seconds"] = 0 14 | data["per_day"] = { 15 | "days": [str(yesterday)], 16 | "pageviews": [0], 17 | "script_runs": [0], 18 | } 19 | data["widgets"] = {} 20 | data["start_time"] = datetime.datetime.now().strftime("%d %b %Y, %H:%M:%S") 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '444B' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | - 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | - 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Software Vesions** 25 | Please fill in the relevant field: 26 | - streamlit_analytics2 : 27 | - streamlit : 28 | - Python3 : 29 | 30 | **Screenshots** 31 | If applicable, add screenshots to help explain your problem. 32 | - 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | - 37 | -------------------------------------------------------------------------------- /examples/pages/create_key.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import toml 4 | 5 | # Create streamlit secrets directory and secrets.toml if it doesn't exist 6 | secrets_dir = "./.streamlit" 7 | secrets_file = f"{secrets_dir}/secrets.toml" 8 | 9 | if not os.path.exists(secrets_dir): 10 | os.mkdir(secrets_dir) 11 | open(secrets_file, "a").close() # 'a' mode to create the file if it doesn't exist 12 | 13 | # Correctly specify the path to the JSON file 14 | json_file_path = "firebase-key.json" # Correctly specify the JSON filename as a string 15 | 16 | # Read the JSON file content 17 | with open(json_file_path) as json_file: 18 | json_text = json_file.read() 19 | 20 | # Prepare the TOML configuration 21 | config = {"firebase_key": json_text} 22 | toml_config = toml.dumps(config) 23 | 24 | # Write the TOML configuration to the secrets file 25 | with open(secrets_file, "w") as target: 26 | target.write(toml_config) 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Briefly describe the changes made in this pull request. 4 | 5 | ## Related Issue(s) 6 | 7 | - Closes #[issue number] 8 | 9 | ## Changes Made 10 | 11 | - [ ] Change 1 12 | - [ ] Change 2 13 | - [ ] Change 3 14 | 15 | ## Testing 16 | 17 | - [ ] I have manually tested these changes and confirm they work as intended. 18 | 19 | **Testing Details:** 20 | Describe how you tested the changes. 21 | 22 | ## Screenshots (if applicable) 23 | 24 | Add any relevant screenshots here. 25 | 26 | ## Checklist 27 | 28 | - [ ] I have read the [CONTRIBUTING](CONTRIBUTING.md) guide. 29 | - [ ] I have commented my code, particularly in hard-to-understand areas. 30 | - [ ] I have made corresponding changes to the documentation (README.md, CONTRIBUTING.md, etc.). 31 | - [ ] My changes generate no new warnings or errors. 32 | 33 | ## Additional Notes 34 | 35 | Add any other notes or context about the pull request here. -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # tests/test_utils.py 2 | import streamlit_analytics2.utils as utils 3 | 4 | 5 | def test_format_seconds(): 6 | # Test cases for format_seconds function 7 | assert utils.format_seconds(0) == "00:00:00", "Should format 0 seconds correctly" 8 | assert ( 9 | utils.format_seconds(3661) == "01:01:01" 10 | ), "Should format 3661 seconds correctly" 11 | assert utils.format_seconds(60) == "00:01:00", "Should format 60 seconds correctly" 12 | assert ( 13 | utils.format_seconds(86399) == "23:59:59" 14 | ), "Should handle edge cases correctly" 15 | 16 | 17 | def test_replace_empty(): 18 | # Test cases for replace_empty function 19 | assert utils.replace_empty("") == " ", "Should replace empty string with a space" 20 | assert utils.replace_empty(None) == " ", "Should replace None with a space" 21 | assert ( 22 | utils.replace_empty("text") == "text" 23 | ), "Should return the original string if not empty" 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | # Add these recommended settings 8 | allow: 9 | # Only handle production dependencies by default 10 | - dependency-type: "production" 11 | commit-message: 12 | prefix: "deps" 13 | include: "scope" 14 | # Group updates together 15 | groups: 16 | dev-dependencies: 17 | patterns: 18 | - "black" 19 | - "isort" 20 | - "flake8" 21 | - "mypy" 22 | - "bandit" 23 | - "pytest*" 24 | production-dependencies: 25 | patterns: 26 | - "streamlit" 27 | - "pandas" 28 | - "altair" 29 | - "google-cloud-firestore" 30 | # Specify target branch 31 | target-branch: "main" 32 | # Set reviewers for the PRs 33 | reviewers: 34 | - "444B" 35 | # Labels for PRs 36 | labels: 37 | - "dependencies" 38 | - "automated" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Johannes Rieke 4 | 5 | Copyright (c) 2024 444B 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 4 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": [ 8 | "README.md", 9 | "examples/pages/sharing-demo.py" 10 | ] 11 | }, 12 | "vscode": { 13 | "settings": {}, 14 | "extensions": [ 15 | "ms-python.python", 16 | "ms-python.vscode-pylance" 17 | ] 18 | } 19 | }, 20 | "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y str: 8 | """Formats seconds to 00:00:00 format.""" 9 | # days, remainder = divmod(s, 86400) 10 | hours, remainder = divmod(s, 3600) 11 | mins, secs = divmod(remainder, 60) 12 | 13 | # days = int(days) 14 | hours = int(hours) 15 | mins = int(mins) 16 | secs = int(secs) 17 | 18 | # output = f"{secs} s" 19 | # if mins: 20 | # output = f"{mins} min, " + output 21 | # if hours: 22 | # output = f"{hours} h, " + output 23 | # if days: 24 | # output = f"{days} days, " + output 25 | output = f"{hours:02}:{mins:02}:{secs:02}" 26 | return output 27 | 28 | 29 | def replace_empty(s): 30 | """Replace an empty string or None with a space.""" 31 | if s == "" or s is None: 32 | return " " 33 | else: 34 | return s 35 | 36 | 37 | def session_data_reset() -> Dict[str, Any]: 38 | """ 39 | Reset the session data to a new session. 40 | 41 | Returns 42 | ------- 43 | Dict[str, Any] 44 | The new session data. 45 | """ 46 | # Use yesterday as first entry to make chart look better. 47 | yesterday = str(datetime.date.today() - datetime.timedelta(days=1)) 48 | output: Dict[str, Any] = {} 49 | output["total_pageviews"] = 0 50 | output["total_script_runs"] = 0 51 | output["total_time_seconds"] = 0 52 | output["per_day"] = { 53 | "days": [str(yesterday)], 54 | "pageviews": [0], 55 | "script_runs": [0], 56 | } 57 | output["widgets"] = {} 58 | output["start_time"] = datetime.datetime.now().strftime( 59 | "%d %b %Y, %H:%M:%S" 60 | ) # noqa: E501 61 | 62 | return output 63 | 64 | 65 | def initialize_session_data(): 66 | """ 67 | Initialize the session data if not already initialized. 68 | """ 69 | if "session_data" not in ss: 70 | ss.session_data = session_data_reset() 71 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions of streamlit-analytics2 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | >= 0.4.3 | :white_check_mark: | 8 | | <= 0.4.2 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | The `streamlit-analytics2` team takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. 13 | 14 | To report a security issue, please email us at [contact+streamlitanalytics2@444b.me]. We'll endeavor to respond quickly, and will keep you updated throughout the process. 15 | 16 | ## Security Analysis Tools 17 | 18 | - **Dependabot**: We use Dependabot to automatically scan for vulnerabilities in our dependencies. It helps us to keep our project secure by updating dependencies to more secure versions. 19 | - **CodeQL Analysis**: For each release, we perform CodeQL analysis to identify vulnerabilities in our codebase. This ensures that the code meets our security standards. 20 | 21 | ## Contributions 22 | 23 | While we encourage public contributions, it's important to note that each contribution will be reviewed for security implications. Contributors are encouraged to follow our [contribution guidelines](CONTRIBUTING.md). 24 | 25 | ## Disclaimer 26 | 27 | Please note that `streamlit-analytics2` is provided on a "best effort" basis. The maintainers take no responsibility for any direct or indirect damage caused due to the usage of this software. Users should understand the risks associated with using open-source software. We will address security issues to the best of our abilities as soon as they are brought to our attention. 28 | 29 | ## Handling Security Issues 30 | 31 | - Security issues in `streamlit-analytics2` will be addressed promptly upon discovery. 32 | - Please understand that we only address issues related to `streamlit-analytics2` directly and have no control over the upstream `streamlit-analytics` project. 33 | - We commit to communicating with you throughout the resolution process. 34 | 35 | Thank you for supporting `streamlit-analytics2` and helping us make the open-source community a safer place. 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.21.0"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "streamlit_analytics2" 7 | dynamic = ["version"] 8 | description = "Track & visualize user interactions with your streamlit app." 9 | authors = [{ name = "444B", email = "contact+pypi@444b.me" }] 10 | license = { file = "LICENSE" } 11 | readme = {file = ".github/README.md", content-type = "text/markdown"} 12 | keywords = ["streamlit", "analytics", "visualization", "streamlit-analytics", "streamlit-analytics2"] 13 | classifiers = [ 14 | "Programming Language :: Python :: 3.9", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Topic :: Database :: Front-Ends", 19 | "Topic :: Scientific/Engineering :: Visualization", 20 | "Topic :: Scientific/Engineering :: Information Analysis", 21 | "Operating System :: OS Independent", 22 | "License :: OSI Approved :: MIT License", 23 | "Intended Audience :: Developers", 24 | "Development Status :: 4 - Beta" 25 | ] 26 | requires-python = ">=3.10" 27 | dependencies = [ 28 | "streamlit>=1.37.0", 29 | "pandas>=2.2.3", 30 | "altair>=5.5.0", 31 | "google-cloud-firestore>=2.19.0", 32 | "typing_extensions>=4.12.2", 33 | "numpy>=2.2.1", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "black>=24.10.0", 39 | "isort>=5.13.2", 40 | "flake8>=7.1.1", 41 | "mypy>=1.14.0", 42 | "bandit>=1.8.0", 43 | "pytest>=8.3.4", 44 | "pytest-cov>=4.0.0", 45 | "pandas-stubs>=2.2.3.241126", 46 | "build>=1.2.2.post1", 47 | "twine>=6.0.1" 48 | ] 49 | test = [ 50 | "pytest>=8.3.4", 51 | "pytest-cov>=4.0.0" 52 | ] 53 | 54 | [project.urls] 55 | Repository = "https://github.com/444B/streamlit-analytics2" 56 | Documentation = "https://github.com/444B/streamlit-analytics2/wiki" 57 | Issues = "https://github.com/444B/streamlit-analytics2/issues" 58 | 59 | [tool.hatch.build] 60 | include = [ 61 | "src/**/*.py", 62 | ] 63 | exclude = [ 64 | "tests/*", 65 | "examples/*", 66 | ] 67 | 68 | [tool.hatch.build.targets.wheel] 69 | packages = ["src/streamlit_analytics2"] 70 | 71 | 72 | [tool.hatch.build.targets.sdist] 73 | include = [ 74 | "/src", 75 | "LICENSE", 76 | ] 77 | 78 | [tool.hatch.version] 79 | path = "src/streamlit_analytics2/__init__.py" 80 | 81 | [tool.hatch.metadata] 82 | allow-direct-references = true 83 | 84 | [tool.black] 85 | line-length = 88 86 | target-version = ["py312"] 87 | include = '\.pyi?$' 88 | extend-exclude = ''' 89 | # A regex preceded with ^/ will apply only to files and directories 90 | # in the root of the project. 91 | ^/docs/ 92 | ''' 93 | 94 | [tool.isort] 95 | profile = "black" 96 | multi_line_output = 3 97 | include_trailing_comma = true 98 | force_grid_wrap = 0 99 | use_parentheses = true 100 | ensure_newline_before_comments = true 101 | line_length = 88 102 | 103 | [tool.mypy] 104 | python_version = "3.10" 105 | strict = true 106 | warn_return_any = true 107 | warn_unused_configs = true 108 | disallow_untyped_defs = true 109 | check_untyped_defs = true 110 | disallow_incomplete_defs = true 111 | disallow_untyped_decorators = true 112 | no_implicit_optional = true 113 | warn_redundant_casts = true 114 | warn_unused_ignores = true 115 | warn_no_return = true 116 | warn_unreachable = true 117 | 118 | [tool.pytest.ini_options] 119 | testpaths = ["tests"] 120 | python_files = ["test_*.py"] 121 | addopts = "-ra -q --cov=src --cov-report=term-missing" 122 | 123 | [tool.coverage.run] 124 | source = ["src"] 125 | branch = true 126 | 127 | [tool.coverage.report] 128 | exclude_lines = [ 129 | "pragma: no cover", 130 | "def __repr__", 131 | "if __name__ == .__main__.:", 132 | "raise NotImplementedError", 133 | "if TYPE_CHECKING:" 134 | ] 135 | 136 | [tool.bandit] 137 | exclude_dirs = ["tests", "docs"] 138 | skips = ["B311"] 139 | 140 | [dependency-groups] 141 | dev = [ 142 | "types-toml>=0.10.8.20240310", 143 | ] 144 | -------------------------------------------------------------------------------- /src/streamlit_analytics2/firestore.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import streamlit as st 4 | from google.cloud import firestore 5 | from google.oauth2 import service_account 6 | from streamlit import session_state as ss 7 | 8 | from .state import data # noqa: F401 9 | 10 | 11 | def sanitize_data(data): # noqa: F811 12 | if isinstance(data, dict): 13 | # Recursively sanitize dictionary keys 14 | return { 15 | str(k) if k else "": sanitize_data(v) for k, v in data.items() if k 16 | } # noqa: E501 17 | elif isinstance(data, list): 18 | # Apply sanitization to elements in lists 19 | return [sanitize_data(item) for item in data] 20 | else: 21 | return data 22 | 23 | 24 | def load( 25 | data, # noqa: F811 26 | service_account_json, 27 | collection_name, 28 | document_name, 29 | streamlit_secrets_firestore_key, 30 | firestore_project_name, 31 | session_id=None, 32 | ): 33 | """Load count data from firestore into `data`.""" 34 | firestore_data = None 35 | firestore_session_data = None 36 | 37 | if streamlit_secrets_firestore_key is not None: 38 | # Following along here 39 | # https://blog.streamlit.io/streamlit-firestore-continued/#part-4-securely-deploying-on-streamlit-sharing # noqa: E501 40 | # for deploying to Streamlit Cloud with Firestore 41 | key_dict = json.loads(st.secrets[streamlit_secrets_firestore_key]) 42 | creds = service_account.Credentials.from_service_account_info(key_dict) 43 | db = firestore.Client(credentials=creds, project=firestore_project_name) 44 | col = db.collection(collection_name) 45 | firestore_data = col.document(document_name).get().to_dict() 46 | if session_id is not None: 47 | firestore_session_data = col.document(session_id).get().to_dict() 48 | else: 49 | db = firestore.Client.from_service_account_json(service_account_json) 50 | col = db.collection(collection_name) 51 | firestore_data = col.document(document_name).get().to_dict() 52 | if session_id is not None: 53 | firestore_session_data = col.document(session_id).get().to_dict() 54 | 55 | if firestore_data is not None: 56 | for key in firestore_data: 57 | if key in data: 58 | data[key] = firestore_data[key] 59 | 60 | if firestore_session_data is not None: 61 | for key in firestore_session_data: 62 | if key in ss.session_data: 63 | ss.session_data[key] = firestore_session_data[key] 64 | 65 | # Log loaded data for debugging 66 | # logging.debug("Data loaded from Firestore: %s", firestore_data) 67 | 68 | 69 | def save( 70 | data, # noqa: F811 71 | service_account_json, 72 | collection_name, 73 | document_name, 74 | streamlit_secrets_firestore_key, 75 | firestore_project_name, 76 | session_id=None, 77 | ): 78 | """Save count data from `data` to firestore.""" 79 | 80 | # Ensure all keys are strings and not empty 81 | sanitized_data = sanitize_data(data) 82 | 83 | if streamlit_secrets_firestore_key is not None: 84 | # Following along here https://blog.streamlit.io/streamlit-firestore-continued/#part-4-securely-deploying-on-streamlit-sharing # noqa: E501 85 | # for deploying to Streamlit Cloud with Firestore 86 | key_dict = json.loads(st.secrets[streamlit_secrets_firestore_key]) 87 | creds = service_account.Credentials.from_service_account_info(key_dict) 88 | db = firestore.Client(credentials=creds, project=firestore_project_name) 89 | else: 90 | db = firestore.Client.from_service_account_json(service_account_json) 91 | col = db.collection(collection_name) 92 | # TODO pass user set argument via config screen for the name of document 93 | # currently hard coded to be "counts" 94 | 95 | # Attempt to save to Firestore 96 | # creates if doesn't exist 97 | col.document(document_name).set(sanitized_data) 98 | if session_id is not None: 99 | sanitized_session_data = sanitize_data(ss.session_data) 100 | col.document(session_id).set(sanitized_session_data) 101 | -------------------------------------------------------------------------------- /.github/README.md: -------------------------------------------------------------------------------- 1 | # Streamlit-Analytics2 2 | 3 | [![PyPi](https://img.shields.io/pypi/v/streamlit-analytics2)](https://pypi.org/project/streamlit-analytics2/) 4 | [![PyPI Downloads](https://static.pepy.tech/badge/streamlit-analytics2)](https://pepy.tech/projects/streamlit-analytics2) 5 | [![PyPI Downloads](https://static.pepy.tech/badge/streamlit-analytics2/month)](https://pepy.tech/projects/streamlit-analytics2) 6 | ![Build Status](https://github.com/444B/streamlit-analytics2/actions/workflows/release.yml/badge.svg) 7 | 8 | [![CodeFactor](https://www.codefactor.io/repository/github/444b/streamlit-analytics2/badge)](https://www.codefactor.io/repository/github/444b/streamlit-analytics2) 9 | ![Coverage](https://codecov.io/gh/444B/streamlit-analytics2/branch/main/graph/badge.svg) 10 | 11 | ![Known Vulnerabilities](https://snyk.io/test/github/444B/streamlit-analytics2/badge.svg) 12 | [![streamlit-analytics2](https://snyk.io/advisor/python/streamlit-analytics2/badge.svg)](https://snyk.io/advisor/python/streamlit-analytics2) 13 | 14 | 15 | ## Check it out here! [👉 Demo 👈](https://sa2analyticsdemo.streamlit.app/?analytics=on) 16 | 17 | Streamlit Analytics2 is an actively maintained, powerful tool for tracking user interactions and gathering insights from your [Streamlit](https://streamlit.io/) applications. With just a few lines of code, you can gain insight into how your app is being used and making data-driven decisions to improve your app. 18 | 19 | > [!Note] 20 | > This fork is confirmed to fix the deprecation ```st.experimental_get_query_params``` alerts. [Context](https://docs.streamlit.io/library/api-reference/utilities/st.experimental_get_query_params) 21 | > It also resolves 41 security issues that exist in the upstream dependencies (4 Critical, 13 High, 21 Moderate, 3 Low) as of Dec 29th 2024 22 | 23 | 24 | ## Getting Started 25 | 26 | 1. Install the package: 27 | ``` 28 | pip install streamlit-analytics2 29 | ``` 30 | 31 | 2. Import and initialize the tracker in your Streamlit script: 32 | ```python 33 | import streamlit as st 34 | import streamlit_analytics2 as streamlit_analytics 35 | 36 | with streamlit_analytics.track(): 37 | st.write("Hello, World!") 38 | st.button("Click me") 39 | ``` 40 | 41 | 3. Run your Streamlit app and append `?analytics=on` to the URL to view the analytics dashboard. 42 | 43 | 44 | ## Getting the most out of Streamlit Analytics2 45 | 46 | Be sure to check out our [Wiki](https://github.com/444B/streamlit-analytics2/wiki) for even more ways to configure the application. 47 | Some features include: 48 | - Storing data to json, CSV or Firestore 49 | - Gathering Session state details based on randomized UUIDs 50 | - Setting passwords for your analytics dashboards 51 | - Migration guides 52 | We welcome contributions to the Wiki as well! 53 | 54 | 55 | ## Contributing 56 | 57 | We're actively seeking additional maintainers to help improve Streamlit Analytics2. If you're interested in contributing, please check out our [Contributing Guidelines](https://github.com/444B/streamlit-analytics2/blob/main/.github/CONTRIBUTING.md) to get started. We welcome pull requests, bug reports, feature requests, and any other feedback. 58 | 59 | 60 | ## Upcoming Features 61 | 62 | We're currently working on a major release that will introduce exciting new features and enhancements: 63 | 64 | - Multi-page tracking: Monitor user interactions across multiple pages of your Streamlit app. 65 | - Improved metrics accuracy: Get more precise and reliable usage metrics. 66 | - Flexible data formats: Choose between CSV or JSON for storing and exporting analytics data. 67 | - Customization screen: Easily configure and customize the analytics settings through a user-friendly interface. 68 | 69 | Stay tuned for more updates and join our [community](https://github.com/444B/streamlit-analytics2/discussions) to be part of shaping the future of Streamlit Analytics2! 70 | 71 | 72 | ## Multipage tracking status: 73 | |Method|Status| 74 | |-|-| 75 | |main.py|✅ (Works)| 76 | |[pages/ directory](https://docs.streamlit.io/develop/concepts/multipage-apps/pages-directory)|❌ (Not Working)| 77 | |[st.Page + st.navigation](https://docs.streamlit.io/develop/concepts/multipage-apps/page-and-navigation)|🤷 (Checking)| 78 | 79 | 80 | ## License 81 | 82 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General access 2 | */api.key 3 | 4 | # streamlit specific 5 | *secrets.toml 6 | config.toml 7 | analytics.toml 8 | 9 | # StreamlitAnalytics2 test specific 10 | test/logs/* 11 | test/logs 12 | test_results_* 13 | errors_* 14 | test_results*.tmp 15 | 16 | # Custom added by 444b 17 | flake8 18 | tree 19 | black 20 | test_results_* 21 | firebase-key.json 22 | firestore-key.json 23 | secrets.toml 24 | .vscode 25 | .devcontainer/* 26 | analytics.toml 27 | 28 | # Byte-compiled / optimized / DLL files 29 | __pycache__/ 30 | *.py[cod] 31 | *$py.class 32 | 33 | # C extensions 34 | *.so 35 | 36 | # Distribution / packaging 37 | .Python 38 | build/ 39 | develop-eggs/ 40 | dist/ 41 | downloads/ 42 | eggs/ 43 | .eggs/ 44 | lib/ 45 | lib64/ 46 | parts/ 47 | sdist/ 48 | var/ 49 | wheels/ 50 | share/python-wheels/ 51 | *.egg-info/ 52 | .installed.cfg 53 | *.egg 54 | MANIFEST 55 | 56 | # PyInstaller 57 | # Usually these files are written by a python script from a template 58 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 59 | *.manifest 60 | *.spec 61 | 62 | # Installer logs 63 | pip-log.txt 64 | pip-delete-this-directory.txt 65 | 66 | # Unit test / coverage reports 67 | htmlcov/ 68 | .tox/ 69 | .nox/ 70 | .coverage 71 | .coverage.* 72 | .cache 73 | nosetests.xml 74 | coverage.xml 75 | *.cover 76 | *.py,cover 77 | .hypothesis/ 78 | .pytest_cache/ 79 | cover/ 80 | 81 | # Translations 82 | *.mo 83 | *.pot 84 | 85 | # Django stuff: 86 | *.log 87 | local_settings.py 88 | db.sqlite3 89 | db.sqlite3-journal 90 | 91 | # Flask stuff: 92 | instance/ 93 | .webassets-cache 94 | 95 | # Scrapy stuff: 96 | .scrapy 97 | 98 | # Sphinx documentation 99 | docs/_build/ 100 | 101 | # PyBuilder 102 | .pybuilder/ 103 | target/ 104 | 105 | # Jupyter Notebook 106 | .ipynb_checkpoints 107 | 108 | # IPython 109 | profile_default/ 110 | ipython_config.py 111 | 112 | # pyenv 113 | # For a library or package, you might want to ignore these files since the code is 114 | # intended to run in multiple environments; otherwise, check them in: 115 | # .python-version 116 | 117 | # pipenv 118 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 119 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 120 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 121 | # install all needed dependencies. 122 | # Pipfile.lock 123 | 124 | # UV 125 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 126 | # This is especially recommended for binary packages to ensure reproducibility, and is more 127 | # commonly ignored for libraries. 128 | # uv.lock 129 | 130 | # poetry 131 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 132 | # This is especially recommended for binary packages to ensure reproducibility, and is more 133 | # commonly ignored for libraries. 134 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 135 | # poetry.lock 136 | 137 | # pdm 138 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 139 | #pdm.lock 140 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 141 | # in version control. 142 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 143 | .pdm.toml 144 | .pdm-python 145 | .pdm-build/ 146 | 147 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 148 | __pypackages__/ 149 | 150 | # Celery stuff 151 | celerybeat-schedule 152 | celerybeat.pid 153 | 154 | # SageMath parsed files 155 | *.sage.py 156 | 157 | # Environments 158 | .env 159 | .venv 160 | env/ 161 | venv/ 162 | ENV/ 163 | env.bak/ 164 | venv.bak/ 165 | 166 | # Spyder project settings 167 | .spyderproject 168 | .spyproject 169 | 170 | # Rope project settings 171 | .ropeproject 172 | 173 | # mkdocs documentation 174 | /site 175 | 176 | # mypy 177 | .mypy_cache/ 178 | .dmypy.json 179 | dmypy.json 180 | 181 | # Pyre type checker 182 | .pyre/ 183 | 184 | # pytype static type analyzer 185 | .pytype/ 186 | 187 | # Cython debug symbols 188 | cython_debug/ 189 | 190 | # PyCharm 191 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 192 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 193 | # and can be added to the global gitignore or merged into this file. For a more nuclear 194 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 195 | #.idea/ 196 | 197 | # PyPI configuration file 198 | .pypirc 199 | -------------------------------------------------------------------------------- /examples/pages/all-features.py: -------------------------------------------------------------------------------- 1 | # This is the file to test all the features in streamlit-analytics2 2 | # When making code changes or refactoring, this is the current main way of 3 | # catching breaking changes 4 | # In time, we will use Streamlits AppTest framework 5 | 6 | import streamlit as st 7 | 8 | import streamlit_analytics2 as streamlit_analytics 9 | 10 | 11 | # Example functions for each test 12 | def test_all_widgets(): 13 | with streamlit_analytics.track(verbose=True): 14 | st.title("Test app with all widgets") 15 | st.checkbox("checkbox") 16 | st.button("button") 17 | st.radio("radio", ("option 1", "option 2")) 18 | st.selectbox("selectbox", ("option 1", "option 2", "option 3")) 19 | st.multiselect("multiselect", ("option 1", "option 2")) 20 | st.slider("slider") 21 | st.slider("double-ended slider", value=[0, 100]) 22 | st.select_slider("select_slider", ("option 1", "option 2")) 23 | st.text_input("text_input") 24 | st.number_input("number_input") 25 | st.text_area("text_area") 26 | st.date_input("date_input") 27 | st.time_input("time_input") 28 | st.file_uploader("file_uploader") 29 | st.color_picker("color_picker") 30 | prompt = st.chat_input("Send a prompt to the bot") 31 | if prompt: 32 | st.write(f"User has sent the following prompt: {prompt}") 33 | 34 | st.sidebar.checkbox("sidebar_checkbox") 35 | st.sidebar.button("sidebar_button") 36 | st.sidebar.radio("sidebar_radio", ("option 1", "option 2")) 37 | st.sidebar.selectbox("sidebar_selectbox", ("option 1", "option 2", "option 3")) 38 | st.sidebar.multiselect("sidebar_multiselect", ("option 1", "option 2")) 39 | st.sidebar.slider("sidebar_slider") 40 | st.sidebar.slider("sidebar_double-ended slider", value=[0, 100]) 41 | st.sidebar.select_slider("sidebar_select_slider", ("option 1", "option 2")) 42 | st.sidebar.text_input("sidebar_text_input") 43 | st.sidebar.number_input("sidebar_number_input") 44 | st.sidebar.text_area("sidebar_text_area") 45 | st.sidebar.date_input("sidebar_date_input") 46 | st.sidebar.time_input("sidebar_time_input") 47 | st.sidebar.file_uploader("sidebar_file_uploader") 48 | st.sidebar.color_picker("sidebar_color_picker") 49 | 50 | 51 | def test_password_protection(): 52 | with streamlit_analytics.track( 53 | verbose=True, unsafe_password=st.secrets["unsafe_password"] 54 | ): 55 | st.markdown( 56 | """ 57 | Testing password protection.... Please enter '?analytics=on' after the URL 58 | There should already be a Key and Value for each widget in Firebase. 59 | """ 60 | ) 61 | 62 | 63 | def test_firebase_storage(): 64 | with streamlit_analytics.track( 65 | verbose=True, 66 | firestore_key_file="firebase-key.json", 67 | firestore_collection_name="streamlit-analytics2", 68 | ): 69 | st.write("You should see this in your firebase dashboard") 70 | st.button("Click Me!") 71 | 72 | 73 | def test_firebase_storage_with_st_secret(): 74 | with streamlit_analytics.track( 75 | verbose=True, 76 | streamlit_secrets_firestore_key="firebase_key", 77 | firestore_project_name=st.secrets["project_name"], 78 | firestore_collection_name=st.secrets["collection_secret"], 79 | ): 80 | st.write("You should see this in your firebase dashboard") 81 | st.button("Click Me!") 82 | 83 | 84 | def test_analytics_track_local_json_storing(): 85 | # requires additional testing to ensure error handling 86 | with streamlit_analytics.track(verbose=True, save_to_json="path/to/file.json"): 87 | st.write("Testing analytics tracking with local JSON storing...") 88 | st.button("Click Me!") 89 | 90 | 91 | def test_analytics_track_local_json_loading(): 92 | # requires additional testing to ensure error handling 93 | with streamlit_analytics.track(verbose=True, load_from_json="path/to/file.json"): 94 | st.write("Testing analytics tracking with local JSON loading...") 95 | st.button("Click Me!") 96 | 97 | 98 | # Dropdown menu for selecting the test 99 | option = st.selectbox( 100 | "Select the functionality to test:", 101 | ( 102 | "Test All Widgets", 103 | "Password Protection", 104 | "Firebase Storage", 105 | "Firebase st.secret use", 106 | "Analytics Track Local JSON Storing", 107 | "Analytics Track Local JSON Loading", 108 | ), 109 | ) 110 | 111 | # Execute the selected option 112 | if option == "Test All Widgets": 113 | test_all_widgets() 114 | elif option == "Password Protection": 115 | test_password_protection() 116 | elif option == "Firebase Storage": 117 | test_firebase_storage() 118 | elif option == "Firebase st.secret use": 119 | test_firebase_storage_with_st_secret() 120 | elif option == "Analytics Track Local JSON Storing": 121 | test_analytics_track_local_json_storing() 122 | elif option == "Analytics Track Local JSON Loading": 123 | test_analytics_track_local_json_loading() 124 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | contact+sa2codeofconduct@444b.me. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /src/streamlit_analytics2/display.py: -------------------------------------------------------------------------------- 1 | """ 2 | Displays the analytics results within streamlit. 3 | """ 4 | 5 | import altair as alt 6 | import pandas as pd 7 | import streamlit as st 8 | 9 | from . import utils 10 | from .state import data # noqa: F401 11 | 12 | 13 | def show_results(data, reset_callback, unsafe_password=None): # noqa: F811 14 | """Show analytics results in streamlit, asking for password if given.""" 15 | 16 | # Show header. 17 | st.title("Analytics Dashboard") 18 | st.markdown( 19 | """ 20 | Psst! 👀 You found a secret section generated by 21 | [streamlit-analytics2](https://github.com/444B/streamlit-analytics2). 22 | If you didn't mean to go here, remove `?analytics=on` from the URL. 23 | """ 24 | ) 25 | 26 | # Ask for password if one was given. 27 | show = True 28 | if unsafe_password is not None: 29 | password_input = st.text_input( 30 | "Enter password to show results", type="password" 31 | ) 32 | if password_input != unsafe_password: 33 | show = False 34 | if len(password_input) > 0: 35 | st.write("Nope, that's not correct ☝️") 36 | 37 | if show: 38 | # Show traffic. 39 | st.header("Traffic") 40 | st.write(f"since {data['start_time']}") 41 | col1, col2, col3 = st.columns(3) 42 | col1.metric( 43 | "Pageviews", 44 | data["total_pageviews"], 45 | help="Every time a user (re-)loads the site.", 46 | ) 47 | col2.metric( 48 | "Widget Interactions", 49 | data["total_script_runs"], 50 | help="Every time Streamlit reruns upon changes or interactions.", 51 | ) 52 | col3.metric( 53 | "Time spent", 54 | utils.format_seconds(data["total_time_seconds"]), 55 | help=( 56 | "Total usage from all users from run to last widget interaction" 57 | ), # noqa: E501 58 | ) 59 | st.write("") 60 | 61 | df = pd.DataFrame(data["per_day"]) 62 | # check if more than one year of data exists 63 | if pd.to_datetime(df["days"]).dt.year.nunique() > 1: 64 | x_axis_ticks = "yearmonthdate(days):O" 65 | else: 66 | x_axis_ticks = "monthdate(days):O" 67 | 68 | base = alt.Chart(df).encode( 69 | x=alt.X(x_axis_ticks, axis=alt.Axis(title="", grid=True)) 70 | ) 71 | line1 = base.mark_line(point=True, stroke="#5276A7").encode( 72 | alt.Y( 73 | "pageviews:Q", 74 | axis=alt.Axis( 75 | titleColor="#5276A7", 76 | tickColor="#5276A7", 77 | labelColor="#5276A7", 78 | format=".0f", 79 | tickMinStep=1, 80 | ), 81 | scale=alt.Scale(domain=(0, df["pageviews"].max() + 1)), 82 | ) 83 | ) 84 | line2 = base.mark_line(point=True, stroke="#57A44C").encode( 85 | alt.Y( 86 | "script_runs:Q", 87 | axis=alt.Axis( 88 | title="script runs", 89 | titleColor="#57A44C", 90 | tickColor="#57A44C", 91 | labelColor="#57A44C", 92 | format=".0f", 93 | tickMinStep=1, 94 | ), 95 | ) 96 | ) 97 | layer = ( 98 | alt.layer(line1, line2) 99 | .resolve_scale(y="independent") 100 | .configure_axis(titleFontSize=15, labelFontSize=12, titlePadding=10) 101 | ) 102 | st.altair_chart(layer, use_container_width=True) 103 | 104 | # Show widget interactions. 105 | st.header("Widget interactions") 106 | st.markdown( 107 | """ 108 | Find out how users interacted with your app! 109 |
110 | Numbers indicate how often a button was clicked, how often a 111 | specific text input was given, ... 112 |
113 | Note: Numbers only increase if the state of the widget 114 | changes, not every time streamlit runs the script. 115 |
116 | If you would like to improve the way the below metrics are 117 | displayed, please open an issue/PR on 118 | [streamlit-analytics2](https://github.com/444B/streamlit-analytics2) 119 | with a clear suggestion 120 | """, 121 | unsafe_allow_html=True, 122 | ) 123 | 124 | # This section controls how the tables on individual widgets are shown 125 | # Before, it was just a json of k/v pairs 126 | # There is still room for improvement and PRs are welcome 127 | for i in data["widgets"].keys(): 128 | st.markdown(f"##### `{i}` Widget Usage") 129 | if type(data["widgets"][i]) is dict: 130 | st.dataframe( 131 | pd.DataFrame( 132 | { 133 | "widget_name": i, 134 | "selected_value": list(data["widgets"][i].keys()), 135 | "number_of_interactions": data["widgets"][ 136 | i 137 | ].values(), # noqa: E501 138 | } 139 | ).sort_values(by="number_of_interactions", ascending=False) 140 | ) 141 | else: 142 | st.dataframe( 143 | pd.DataFrame( 144 | { 145 | "widget_name": i, 146 | "number_of_interactions": data["widgets"][i], 147 | }, 148 | index=[0], 149 | ).sort_values(by="number_of_interactions", ascending=False) 150 | ) 151 | 152 | # Show button to reset analytics. 153 | st.header("Danger zone") 154 | with st.expander("Here be dragons 🐲🔥"): 155 | st.write( 156 | """ 157 | Here you can reset all analytics results. 158 | **This will erase everything tracked so far. You will not be 159 | able to retrieve it. This will also overwrite any results 160 | synced to Firestore.** 161 | """ 162 | ) 163 | reset_prompt = st.selectbox( 164 | "Continue?", 165 | [ 166 | "No idea what I'm doing here", 167 | "I'm sure that I want to reset the results", 168 | ], 169 | ) 170 | if reset_prompt == "I'm sure that I want to reset the results": 171 | reset_clicked = st.button("Click here to reset") 172 | if reset_clicked: 173 | reset_callback() 174 | st.write("Done! Please refresh the page.") 175 | -------------------------------------------------------------------------------- /src/streamlit_analytics2/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import streamlit as st 5 | import toml 6 | 7 | # import logging 8 | 9 | # Set up logging 10 | # logging.basicConfig(level=logging.INFO) 11 | # logger = logging.getLogger(__name__) 12 | 13 | # Default configuration 14 | DEFAULT_CONFIG = { 15 | "streamlit_analytics2": {"enabled": True}, 16 | "storage": { 17 | "save": False, 18 | "type": "json", 19 | "save_to_json": "path/to/file.json", 20 | "load_from_json": "path/to/file.json", 21 | }, 22 | "logs": {"verbose": False}, 23 | "access": {"unsafe_password": "hunter2"}, 24 | "firestore": { 25 | "enabled": False, 26 | "firestore_key_file": "firebase-key.json", 27 | "firestore_project_name": "", 28 | "firestore_collection_name": "streamlit_analytics2", 29 | # "firestore_document_name": "data", 30 | "streamlit_secrets_firestore_key": "", 31 | }, 32 | "session": {"session_id": ""}, 33 | } 34 | 35 | 36 | def ensure_streamlit_dir(): 37 | """Ensure .streamlit directory exists""" 38 | Path(".streamlit").mkdir(exist_ok=True) 39 | 40 | 41 | def load_analytics_config(): 42 | """Load analytics configuration with fallback to defaults""" 43 | path = os.path.join(os.getcwd(), ".streamlit/analytics.toml") 44 | # logger.info(f"Loading configuration from: {path}") 45 | 46 | try: 47 | if not os.path.exists(path): 48 | # logger.warning("Configuration file not found. 49 | # Creating with defaults.") 50 | ensure_streamlit_dir() 51 | save_config(DEFAULT_CONFIG) 52 | return DEFAULT_CONFIG.copy() 53 | 54 | with open(path, "r") as file: 55 | config = toml.load(file) 56 | 57 | # Check if file is empty or missing required sections 58 | if not config or "streamlit_analytics2" not in config: 59 | # logger.warning("Invalid configuration found. 60 | # Resetting to defaults.") 61 | save_config(DEFAULT_CONFIG) 62 | return DEFAULT_CONFIG.copy() 63 | 64 | return config 65 | 66 | except Exception as e: # noqa: F841 67 | # logger.error(f"Error loading configuration: {str(e)}") 68 | st.error("Error loading configuration. Using defaults.") 69 | return DEFAULT_CONFIG.copy() 70 | 71 | 72 | def save_config(config): 73 | """Save configuration to file""" 74 | path = os.path.join(os.getcwd(), ".streamlit/analytics.toml") 75 | try: 76 | ensure_streamlit_dir() 77 | with open(path, "w") as file: 78 | toml.dump(config, file) 79 | new_config = config # noqa: F841 80 | # logger.info("Configuration saved successfully") 81 | except Exception as e: # noqa: F841 82 | # logger.error(f"Error saving configuration: {str(e)}") 83 | st.error("Failed to save configuration") 84 | raise 85 | 86 | 87 | def show_config(): 88 | """Display and manage configuration""" 89 | st.title("Analytics Configuration") 90 | st.markdown( 91 | """ 92 | This config page serves as a proof of concept for all SA2 existing features 93 | and some that dont exist yet (like CSV)\ 94 | The Buttons do not currently do anything - please make a PR to help 95 | implement them.\ 96 | To learn how to use all these features, please visit the 97 | [Wiki](https://github.com/444B/streamlit-analytics2/wiki) 98 | 99 | > This will create a .streamlit/analytics.toml in the directory that you 100 | > ran `streamlit run ...`\ 101 | > You can edit the values in the text file directly if its easier 102 | 103 | """ 104 | ) 105 | 106 | # Load current config 107 | config = load_analytics_config() 108 | 109 | # Configuration inputs for streamlit_analytics2 110 | enabled = st.checkbox( 111 | "Enable Streamlit_Analytics2", 112 | value=config["streamlit_analytics2"]["enabled"], 113 | ) 114 | st.divider() 115 | storage_save = st.checkbox("Store Data", value=config["storage"]["save"]) 116 | storage_type = st.radio( 117 | "Storage type", 118 | ["json", "CSV"], 119 | horizontal=True, 120 | index=0 if config["storage"]["type"] == "json" else 1, 121 | ) 122 | save_path = st.text_input( 123 | "Save File Path", value=config["storage"]["save_to_json"] 124 | ) # noqa: E501 125 | load_path = st.text_input( 126 | "Load File Path", value=config["storage"]["load_from_json"] 127 | ) 128 | st.divider() 129 | verbose_logging = st.checkbox( 130 | "Verbose Logging", value=config["logs"]["verbose"] 131 | ) # noqa: E501 132 | st.divider() 133 | password = st.text_input( 134 | "Access Password", 135 | value=config["access"]["unsafe_password"], 136 | type="password", 137 | ) 138 | st.divider() 139 | firestore_enabled = st.checkbox( 140 | "Enable Firestore", value=config["firestore"]["enabled"] 141 | ) 142 | firestore_key_file = st.text_input( 143 | "Firestore Key File Path", 144 | value=config["firestore"]["firestore_key_file"], 145 | ) 146 | firestore_project = st.text_input( 147 | "Firestore Project Name", 148 | value=config["firestore"]["firestore_project_name"], 149 | ) 150 | firestore_collection = st.text_input( 151 | "Firestore Collection Name", 152 | value=config["firestore"]["firestore_collection_name"], 153 | ) 154 | # firestore_document = st.text_input( 155 | # "Firestore Document Name", 156 | # value=config["firestore"]["firestore_document_name"], 157 | # ) 158 | firestore_secret_key = st.text_input( 159 | "Firestore Secret Key", 160 | value=config["firestore"]["streamlit_secrets_firestore_key"], 161 | type="password", 162 | ) 163 | st.divider() 164 | session_id = st.text_input( 165 | "Session ID", value=config["session"]["session_id"] 166 | ) # noqa: E501 167 | st.divider() 168 | 169 | # Create new config from inputs 170 | new_config = { 171 | "streamlit_analytics2": {"enabled": enabled}, 172 | "storage": { 173 | "save": storage_save, 174 | "type": storage_type, 175 | "save_to_json": save_path, 176 | "load_from_json": load_path, 177 | }, 178 | "logs": {"verbose": verbose_logging}, 179 | "access": {"unsafe_password": password}, 180 | "firestore": { 181 | "enabled": firestore_enabled, 182 | "firestore_key_file": firestore_key_file, 183 | "firestore_project_name": firestore_project, 184 | "firestore_collection_name": firestore_collection, 185 | # "firestore_document_name": firestore_document, 186 | "streamlit_secrets_firestore_key": firestore_secret_key, 187 | }, 188 | "session": {"session_id": session_id}, 189 | } 190 | 191 | st.subheader("Current Configuration") 192 | st.write( 193 | "This is the final JSON that will get parsed to TOML in .streamlit/analytics.toml" # noqa: E501 194 | ) 195 | st.json(new_config) 196 | 197 | col1, col2 = st.columns(2) 198 | 199 | with col1: 200 | # Save button 201 | if st.button("Save Configuration", type="primary"): 202 | try: 203 | save_config(new_config) 204 | st.success("Configuration saved!") 205 | except Exception: 206 | st.error("Failed to save configuration. Please check logs.") 207 | 208 | with col2: 209 | # Reset to defaults button 210 | if st.button("↻ Reset to Defaults"): 211 | save_config(DEFAULT_CONFIG) 212 | st.success("Configuration reset to defaults!") 213 | new_config = DEFAULT_CONFIG 214 | st.rerun() 215 | -------------------------------------------------------------------------------- /src/streamlit_analytics2/widgets.py: -------------------------------------------------------------------------------- 1 | # import streamlit as st 2 | 3 | 4 | # def copy_original(): 5 | # # Store original streamlit functions. They will be monkey-patched with 6 | # some wrappers in `start_tracking` (see wrapper functions below). 7 | # _orig_button = st.button 8 | # _orig_checkbox = st.checkbox 9 | # _orig_radio = st.radio 10 | # _orig_selectbox = st.selectbox 11 | # _orig_multiselect = st.multiselect 12 | # _orig_slider = st.slider 13 | # _orig_select_slider = st.select_slider 14 | # _orig_text_input = st.text_input 15 | # _orig_number_input = st.number_input 16 | # _orig_text_area = st.text_area 17 | # _orig_date_input = st.date_input 18 | # _orig_time_input = st.time_input 19 | # _orig_file_uploader = st.file_uploader 20 | # _orig_color_picker = st.color_picker 21 | # # new elements, testing 22 | # # _orig_download_button = st.download_button 23 | # # _orig_link_button = st.link_button 24 | # # _orig_page_link = st.page_link 25 | # # _orig_toggle = st.toggle 26 | # # _orig_camera_input = st.camera_input 27 | # _orig_chat_input = st.chat_input 28 | # # _orig_searchbox = st_searchbox 29 | 30 | # _orig_sidebar_button = st.sidebar.button 31 | # _orig_sidebar_checkbox = st.sidebar.checkbox 32 | # _orig_sidebar_radio = st.sidebar.radio 33 | # _orig_sidebar_selectbox = st.sidebar.selectbox 34 | # _orig_sidebar_multiselect = st.sidebar.multiselect 35 | # _orig_sidebar_slider = st.sidebar.slider 36 | # _orig_sidebar_select_slider = st.sidebar.select_slider 37 | # _orig_sidebar_text_input = st.sidebar.text_input 38 | # _orig_sidebar_number_input = st.sidebar.number_input 39 | # _orig_sidebar_text_area = st.sidebar.text_area 40 | # _orig_sidebar_date_input = st.sidebar.date_input 41 | # _orig_sidebar_time_input = st.sidebar.time_input 42 | # _orig_sidebar_file_uploader = st.sidebar.file_uploader 43 | # _orig_sidebar_color_picker = st.sidebar.color_picker 44 | # # _orig_sidebar_searchbox = st.sidebar.st_searchbox 45 | # # new elements, testing 46 | # # _orig_sidebar_download_button = st.sidebar.download_button 47 | # # _orig_sidebar_link_button = st.sidebar.link_button 48 | # # _orig_sidebar_page_link = st.sidebar.page_link 49 | # # _orig_sidebar_toggle = st.sidebar.toggle 50 | # # _orig_sidebar_camera_input = st.sidebar.camera_input 51 | 52 | 53 | # def monkey_patch(): 54 | # # Monkey-patch streamlit to call the wrappers above. 55 | # st.button = _wrap.button(_orig_button) 56 | # st.checkbox = _wrap.checkbox(_orig_checkbox) 57 | # st.radio = _wrap.select(_orig_radio) 58 | # st.selectbox = _wrap.select(_orig_selectbox) 59 | # st.multiselect = _wrap.multiselect(_orig_multiselect) 60 | # st.slider = _wrap.value(_orig_slider) 61 | # st.select_slider = _wrap.select(_orig_select_slider) 62 | # st.text_input = _wrap.value(_orig_text_input) 63 | # st.number_input = _wrap.value(_orig_number_input) 64 | # st.text_area = _wrap.value(_orig_text_area) 65 | # st.date_input = _wrap.value(_orig_date_input) 66 | # st.time_input = _wrap.value(_orig_time_input) 67 | # st.file_uploader = _wrap.file_uploader(_orig_file_uploader) 68 | # st.color_picker = _wrap.value(_orig_color_picker) 69 | # # new elements, testing 70 | # # st.download_button = _wrap.value(_orig_download_button) 71 | # # st.link_button = _wrap.value(_orig_link_button) 72 | # # st.page_link = _wrap.value(_orig_page_link) 73 | # # st.toggle = _wrap.value(_orig_toggle) 74 | # # st.camera_input = _wrap.value(_orig_camera_input) 75 | # st.chat_input = _wrap.chat_input(_orig_chat_input) 76 | # # st_searchbox = _wrap.searchbox(_orig_searchbox) 77 | 78 | # st.sidebar.button = _wrap.button(_orig_sidebar_button) 79 | # st.sidebar.checkbox = _wrap.checkbox(_orig_sidebar_checkbox) 80 | # st.sidebar.radio = _wrap.select(_orig_sidebar_radio) 81 | # st.sidebar.selectbox = _wrap.select(_orig_sidebar_selectbox) 82 | # st.sidebar.multiselect = _wrap.multiselect(_orig_sidebar_multiselect) 83 | # st.sidebar.slider = _wrap.value(_orig_sidebar_slider) 84 | # st.sidebar.select_slider = _wrap.select(_orig_sidebar_select_slider) 85 | # st.sidebar.text_input = _wrap.value(_orig_sidebar_text_input) 86 | # st.sidebar.number_input = _wrap.value(_orig_sidebar_number_input) 87 | # st.sidebar.text_area = _wrap.value(_orig_sidebar_text_area) 88 | # st.sidebar.date_input = _wrap.value(_orig_sidebar_date_input) 89 | # st.sidebar.time_input = _wrap.value(_orig_sidebar_time_input) 90 | # st.sidebar.file_uploader= _wrap.file_uploader(_orig_sidebar_file_uploader) 91 | # st.sidebar.color_picker = _wrap.value(_orig_sidebar_color_picker) 92 | # # st.sidebar.st_searchbox = _wrap.searchbox(_orig_sidebar_searchbox) 93 | 94 | # # new elements, testing 95 | # # st.sidebar.download_button = _wrap.value(_orig_sidebar_download_button) 96 | # # st.sidebar.link_button = _wrap.value(_orig_sidebar_link_button) 97 | # # st.sidebar.page_link = _wrap.value(_orig_sidebar_page_link) 98 | # # st.sidebar.toggle = _wrap.value(_orig_sidebar_toggle) 99 | # # st.sidebar.camera_input = _wrap.value(_orig_sidebar_camera_input) 100 | 101 | # # replacements = { 102 | # # "button": _wrap.bool, 103 | # # "checkbox": _wrap.bool, 104 | # # "radio": _wrap.select, 105 | # # "selectbox": _wrap.select, 106 | # # "multiselect": _wrap.multiselect, 107 | # # "slider": _wrap.value, 108 | # # "select_slider": _wrap.select, 109 | # # "text_input": _wrap.value, 110 | # # "number_input": _wrap.value, 111 | # # "text_area": _wrap.value, 112 | # # "date_input": _wrap.value, 113 | # # "time_input": _wrap.value, 114 | # # "file_uploader": _wrap.file_uploader, 115 | # # "color_picker": _wrap.value, 116 | # # } 117 | 118 | 119 | # def reset_widgets(): 120 | # # Reset streamlit functions. 121 | # st.button = _orig_button 122 | # st.checkbox = _orig_checkbox 123 | # st.radio = _orig_radio 124 | # st.selectbox = _orig_selectbox 125 | # st.multiselect = _orig_multiselect 126 | # st.slider = _orig_slider 127 | # st.select_slider = _orig_select_slider 128 | # st.text_input = _orig_text_input 129 | # st.number_input = _orig_number_input 130 | # st.text_area = _orig_text_area 131 | # st.date_input = _orig_date_input 132 | # st.time_input = _orig_time_input 133 | # st.file_uploader = _orig_file_uploader 134 | # st.color_picker = _orig_color_picker 135 | # # new elements, testing 136 | # # st.download_button = _orig_download_button 137 | # # st.link_button = _orig_link_button 138 | # # st.page_link = _orig_page_link 139 | # # st.toggle = _orig_toggle 140 | # # st.camera_input = _orig_camera_input 141 | # st.chat_input = _orig_chat_input 142 | # # st.searchbox = _orig_searchbox 143 | # st.sidebar.button = _orig_sidebar_button # type: ignore 144 | # st.sidebar.checkbox = _orig_sidebar_checkbox # type: ignore 145 | # st.sidebar.radio = _orig_sidebar_radio # type: ignore 146 | # st.sidebar.selectbox = _orig_sidebar_selectbox # type: ignore 147 | # st.sidebar.multiselect = _orig_sidebar_multiselect # type: ignore 148 | # st.sidebar.slider = _orig_sidebar_slider # type: ignore 149 | # st.sidebar.select_slider = _orig_sidebar_select_slider # type: ignore 150 | # st.sidebar.text_input = _orig_sidebar_text_input # type: ignore 151 | # st.sidebar.number_input = _orig_sidebar_number_input # type: ignore 152 | # st.sidebar.text_area = _orig_sidebar_text_area # type: ignore 153 | # st.sidebar.date_input = _orig_sidebar_date_input # type: ignore 154 | # st.sidebar.time_input = _orig_sidebar_time_input # type: ignore 155 | # st.sidebar.file_uploader = _orig_sidebar_file_uploader # type: ignore 156 | # st.sidebar.color_picker = _orig_sidebar_color_picker # type: ignore 157 | # # new elements, testing 158 | # # st.sidebar.download_button = _orig_sidebar_download_button 159 | # # st.sidebar.link_button = _orig_sidebar_link_button 160 | # # st.sidebar.page_link = _orig_sidebar_page_link 161 | # # st.sidebar.toggle = _orig_sidebar_toggle 162 | # # st.sidebar.camera_input = _orig_sidebar_camera_input 163 | # # st.sidebar.searchbox = _orig_sidebar_searchbox 164 | # # Save data to firestore. 165 | # # TODO: Maybe don't save on every iteration but on regular intervals in a 166 | # # background thread. 167 | -------------------------------------------------------------------------------- /src/streamlit_analytics2/wrappers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import streamlit as st 4 | from streamlit import session_state as ss 5 | 6 | from . import utils 7 | from .state import data 8 | 9 | 10 | def checkbox(func): 11 | """ 12 | Wrap st.checkbox. 13 | """ 14 | 15 | def new_func(label, *args, **kwargs): 16 | checked = func(label, *args, **kwargs) 17 | label = utils.replace_empty(label) 18 | 19 | # Update aggregate data 20 | if label not in data["widgets"]: 21 | data["widgets"][label] = 0 22 | if checked != st.session_state.state_dict.get(label, None): 23 | data["widgets"][label] += 1 24 | 25 | # Update session data 26 | if label not in ss.session_data["widgets"]: 27 | ss.session_data["widgets"][label] = 0 28 | if checked != st.session_state.state_dict.get(label, None): 29 | ss.session_data["widgets"][label] += 1 30 | 31 | st.session_state.state_dict[label] = checked 32 | return checked 33 | 34 | return new_func 35 | 36 | 37 | def button(func): 38 | """ 39 | Wrap st.button. 40 | """ 41 | 42 | def new_func(label, *args, **kwargs): 43 | clicked = func(label, *args, **kwargs) 44 | label = utils.replace_empty(label) 45 | 46 | # Update aggregate data 47 | if label not in data["widgets"]: 48 | data["widgets"][label] = 0 49 | if clicked: 50 | data["widgets"][label] += 1 51 | 52 | # Update session data 53 | if label not in ss.session_data["widgets"]: 54 | ss.session_data["widgets"][label] = 0 55 | if clicked: 56 | ss.session_data["widgets"][label] += 1 57 | 58 | st.session_state.state_dict[label] = clicked 59 | return clicked 60 | 61 | return new_func 62 | 63 | 64 | def file_uploader(func): 65 | """ 66 | Wrap st.file_uploader. 67 | """ 68 | 69 | def new_func(label, *args, **kwargs): 70 | uploaded_file = func(label, *args, **kwargs) 71 | label = utils.replace_empty(label) 72 | 73 | # Update aggregate data 74 | if label not in data["widgets"]: 75 | data["widgets"][label] = 0 76 | # TODO: Right now this doesn't track when multiple files are uploaded 77 | # one after another. Maybe compare files directly (but probably not 78 | # very clever to store in session state) or hash them somehow and check 79 | # if a different file was uploaded. 80 | if uploaded_file and not st.session_state.state_dict.get(label, None): 81 | data["widgets"][label] += 1 82 | 83 | # Update session data 84 | if label not in ss.session_data["widgets"]: 85 | ss.session_data["widgets"][label] = 0 86 | if uploaded_file and not st.session_state.state_dict.get(label, None): 87 | ss.session_data["widgets"][label] += 1 88 | 89 | st.session_state.state_dict[label] = bool(uploaded_file) 90 | return uploaded_file 91 | 92 | return new_func 93 | 94 | 95 | def select(func): 96 | """ 97 | Wrap a streamlit function that returns one selected element out of multiple 98 | options 99 | e.g. st.radio, st.selectbox, st.select_slider. 100 | """ 101 | 102 | def new_func(label, options, *args, **kwargs): 103 | orig_selected = func(label, options, *args, **kwargs) 104 | label = utils.replace_empty(label) 105 | selected = utils.replace_empty(orig_selected) 106 | 107 | # Update aggregate data 108 | if label not in data["widgets"]: 109 | data["widgets"][label] = {} 110 | for option in options: 111 | option = utils.replace_empty(option) 112 | if option not in data["widgets"][label]: 113 | data["widgets"][label][option] = 0 114 | if selected != st.session_state.state_dict.get(label, None): 115 | data["widgets"][label][selected] += 1 116 | 117 | # Update session data 118 | if label not in ss.session_data["widgets"]: 119 | ss.session_data["widgets"][label] = {} 120 | for option in options: 121 | option = utils.replace_empty(option) 122 | if option not in ss.session_data["widgets"][label]: 123 | ss.session_data["widgets"][label][option] = 0 124 | if selected != st.session_state.state_dict.get(label, None): 125 | ss.session_data["widgets"][label][selected] += 1 126 | 127 | st.session_state.state_dict[label] = selected 128 | return orig_selected 129 | 130 | return new_func 131 | 132 | 133 | def multiselect(func): 134 | """ 135 | Wrap a streamlit function that returns multiple selected elements out of 136 | multiple options, e.g. st.multiselect. 137 | """ 138 | 139 | def new_func(label, options, *args, **kwargs): 140 | selected = func(label, options, *args, **kwargs) 141 | label = utils.replace_empty(label) 142 | 143 | # Update aggregate data 144 | if label not in data["widgets"]: 145 | data["widgets"][label] = {} 146 | for option in options: 147 | option = utils.replace_empty(option) 148 | if option not in data["widgets"][label]: 149 | data["widgets"][label][option] = 0 150 | for sel in selected: 151 | sel = utils.replace_empty(sel) 152 | if sel not in st.session_state.state_dict.get(label, []): 153 | data["widgets"][label][sel] += 1 154 | 155 | # Update session data 156 | if label not in ss.session_data["widgets"]: 157 | ss.session_data["widgets"][label] = {} 158 | for option in options: 159 | option = utils.replace_empty(option) 160 | if option not in ss.session_data["widgets"][label]: 161 | ss.session_data["widgets"][label][option] = 0 162 | for sel in selected: 163 | sel = utils.replace_empty(sel) 164 | if sel not in st.session_state.state_dict.get(label, []): 165 | ss.session_data["widgets"][label][sel] += 1 166 | 167 | st.session_state.state_dict[label] = selected 168 | return selected 169 | 170 | return new_func 171 | 172 | 173 | def value(func): 174 | """ 175 | Wrap a streamlit function that returns a single value, 176 | e.g. st.slider, st.text_input, st.number_input, st.text_area, st.date_input, 177 | st.time_input, st.color_picker. 178 | """ 179 | 180 | def new_func(label, *args, **kwargs): 181 | value = func(label, *args, **kwargs) 182 | 183 | # Update aggregate data 184 | if label not in data["widgets"]: 185 | data["widgets"][label] = {} 186 | 187 | # Update session data 188 | if label not in ss.session_data["widgets"]: 189 | ss.session_data["widgets"][label] = {} 190 | 191 | formatted_value = utils.replace_empty(value) 192 | if type(value) is tuple and len(value) == 2: 193 | # Double-ended slider or date input with start/end, convert to str. 194 | formatted_value = f"{value[0]} - {value[1]}" 195 | 196 | # st.date_input and st.time return datetime object, convert to str 197 | if ( 198 | isinstance(value, datetime.datetime) 199 | or isinstance(value, datetime.date) 200 | or isinstance(value, datetime.time) 201 | ): 202 | formatted_value = str(value) 203 | 204 | if formatted_value not in data["widgets"][label]: 205 | data["widgets"][label][formatted_value] = 0 206 | if formatted_value not in ss.session_data["widgets"][label]: 207 | ss.session_data["widgets"][label][formatted_value] = 0 208 | 209 | if formatted_value != st.session_state.state_dict.get(label, None): 210 | data["widgets"][label][formatted_value] += 1 211 | ss.session_data["widgets"][label][formatted_value] += 1 212 | 213 | st.session_state.state_dict[label] = formatted_value 214 | return value 215 | 216 | return new_func 217 | 218 | 219 | def chat_input(func): 220 | """ 221 | Wrap a streamlit function that returns a single value, 222 | e.g. st.slider, st.text_input, st.number_input, st.text_area, st.date_input, 223 | st.time_input, st.color_picker. 224 | """ 225 | 226 | def new_func(placeholder, *args, **kwargs): 227 | value = func(placeholder, *args, **kwargs) 228 | 229 | # Update aggregate data 230 | if placeholder not in data["widgets"]: 231 | data["widgets"][placeholder] = {} 232 | 233 | # Update session data 234 | if placeholder not in ss.session_data["widgets"]: 235 | ss.session_data["widgets"][placeholder] = {} 236 | 237 | formatted_value = str(value) 238 | 239 | if formatted_value not in data["widgets"][placeholder]: 240 | data["widgets"][placeholder][formatted_value] = 0 241 | if formatted_value not in ss.session_data["widgets"][placeholder]: 242 | ss.session_data["widgets"][placeholder][formatted_value] = 0 243 | 244 | if formatted_value != st.session_state.state_dict.get(placeholder): 245 | data["widgets"][placeholder][formatted_value] += 1 246 | ss.session_data["widgets"][placeholder][formatted_value] += 1 247 | 248 | st.session_state.state_dict[placeholder] = formatted_value 249 | return value 250 | 251 | return new_func 252 | -------------------------------------------------------------------------------- /tests/run_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Define color codes 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | YELLOW='\033[1;33m' 7 | BLUE='\033[0;34m' 8 | CYAN='\033[0;36m' 9 | NO_COLOR='\033[0m' 10 | 11 | # Strict mode - but we'll handle errors ourselves 12 | set -uo pipefail 13 | 14 | # Global variables 15 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 16 | SRC_DIR="${SCRIPT_DIR}/../src/streamlit_analytics2" 17 | VENV_DIR="${SCRIPT_DIR}/.venv" 18 | REQUIREMENTS_FILE="${SCRIPT_DIR}/../pyproject.toml" 19 | LOG_DIR="${SCRIPT_DIR}/logs" 20 | TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S") 21 | RESULTS_FILE="${LOG_DIR}/test_results_${TIMESTAMP}.md" 22 | ERROR_LOG="${LOG_DIR}/errors_${TIMESTAMP}.log" 23 | PARALLEL_JOBS=6 24 | GLOBAL_ERROR=0 25 | 26 | # Initialize log directory 27 | mkdir -p "${LOG_DIR}" 28 | 29 | # Improved error handler function 30 | error_handler() { 31 | local exit_code=$1 32 | local line_no=$2 33 | local last_command=$3 34 | 35 | # Only log actual errors (ignore expected failures from tests/checks) 36 | if [[ $exit_code -ne 0 && "$last_command" != *"run_check"* ]]; then 37 | echo -e "\n${RED}Script error occurred:${NO_COLOR}" 38 | echo -e "${RED}Line: ${line_no}${NO_COLOR}" 39 | echo -e "${RED}Command: ${last_command}${NO_COLOR}" 40 | echo -e "${RED}Exit code: ${exit_code}${NO_COLOR}" 41 | 42 | # Log the error 43 | { 44 | echo "Timestamp: $(date)" 45 | echo "Script error occurred:" 46 | echo "Line: ${line_no}" 47 | echo "Command: ${last_command}" 48 | echo "Exit code: ${exit_code}" 49 | echo "----------------" 50 | } >> "${ERROR_LOG}" 51 | 52 | GLOBAL_ERROR=1 53 | fi 54 | } 55 | 56 | trap 'error_handler $? ${LINENO} "$BASH_COMMAND"' ERR 57 | 58 | # Help function with improved documentation 59 | show_help() { 60 | cat << EOF 61 | Usage: ./run_checks.sh [OPTIONS] 62 | 63 | A comprehensive code quality check script that runs various formatters, 64 | linters, and tests on your Python codebase. 65 | 66 | Options: 67 | -h, --help Show this help message 68 | -f, --fix Run formatters and linters in fix mode 69 | -p, --parallel Run checks in parallel (default: serial) 70 | -v, --verbose Show detailed output 71 | -s, --skip-venv Skip virtual environment check/creation 72 | --no-format Skip format checks 73 | --no-lint Skip lint checks 74 | --no-type Skip type checks 75 | --no-test Skip tests 76 | --no-security Skip security checks 77 | --ci Run in CI mode (implies --skip-venv) 78 | 79 | Examples: 80 | ./run_checks.sh # Run all checks in check-only mode 81 | ./run_checks.sh --fix # Run and fix formatting issues 82 | ./run_checks.sh --parallel # Run checks in parallel 83 | ./run_checks.sh --no-format --fix # Run all checks except formatting in fix mode 84 | 85 | Environment Variables: 86 | PYTHON_VERSION Python version to use (default: 3.10) 87 | COVERAGE_THRESHOLD Minimum required coverage percentage (default: 80) 88 | CI Set to 'true' when running in CI environment 89 | 90 | EOF 91 | exit 0 92 | } 93 | 94 | # Parse command line arguments 95 | FIX_MODE=false 96 | PARALLEL_MODE=false 97 | VERBOSE=false 98 | SKIP_VENV=false 99 | RUN_FORMAT=true 100 | RUN_LINT=true 101 | RUN_TYPE=true 102 | RUN_TEST=true 103 | RUN_SECURITY=true 104 | CI_MODE=false 105 | 106 | while (( "$#" )); do 107 | case "$1" in 108 | -h|--help) 109 | show_help 110 | ;; 111 | -f|--fix) 112 | FIX_MODE=true 113 | shift 114 | ;; 115 | -p|--parallel) 116 | PARALLEL_MODE=true 117 | shift 118 | ;; 119 | -v|--verbose) 120 | VERBOSE=true 121 | shift 122 | ;; 123 | -s|--skip-venv) 124 | SKIP_VENV=true 125 | shift 126 | ;; 127 | --no-format) 128 | RUN_FORMAT=false 129 | shift 130 | ;; 131 | --no-lint) 132 | RUN_LINT=false 133 | shift 134 | ;; 135 | --no-type) 136 | RUN_TYPE=false 137 | shift 138 | ;; 139 | --no-test) 140 | RUN_TEST=false 141 | shift 142 | ;; 143 | --no-security) 144 | RUN_SECURITY=false 145 | shift 146 | ;; 147 | --ci) 148 | CI_MODE=true 149 | SKIP_VENV=true 150 | shift 151 | ;; 152 | *) 153 | echo -e "${RED}Error: Unknown option $1${NO_COLOR}" 154 | show_help 155 | ;; 156 | esac 157 | done 158 | 159 | # Function to print formatted section headers 160 | print_section() { 161 | local title="$1" 162 | local char="${2:-=}" 163 | local width=50 164 | local padding=$(( (width - ${#title} - 2) / 2 )) 165 | local line=$(printf "%${width}s" | tr " " "$char") 166 | echo -e "\n${BLUE}${line}${NO_COLOR}" 167 | echo -e "${BLUE}${char}${char}${NO_COLOR} ${CYAN}${title}${NO_COLOR} ${BLUE}${char}${char}${NO_COLOR}" 168 | echo -e "${BLUE}${line}${NO_COLOR}\n" 169 | } 170 | 171 | # Function to check command existence 172 | check_command() { 173 | local cmd="$1" 174 | if ! command -v "$cmd" >/dev/null 2>&1; then 175 | echo -e "${RED}Error: ${cmd} is not installed${NO_COLOR}" 176 | echo "Please install it first. You can try:" 177 | case "$cmd" in 178 | uv) 179 | echo "pip install uv" 180 | ;; 181 | black|isort|flake8|mypy|bandit|pytest) 182 | echo "pip install $cmd" 183 | ;; 184 | *) 185 | echo "Unable to provide installation instructions for $cmd" 186 | ;; 187 | esac 188 | exit 1 189 | fi 190 | } 191 | 192 | # Improved run_check function with better error handling 193 | run_check() { 194 | local name="$1" 195 | local cmd="$2" 196 | local start_time=$(date +%s) 197 | local check_failed=0 198 | 199 | print_section "Running ${name}" 200 | 201 | if [ "$VERBOSE" = true ]; then 202 | echo "Command: $cmd" 203 | fi 204 | 205 | # Create a temporary file for command output 206 | local temp_output=$(mktemp) 207 | 208 | # Run the command and capture output 209 | if eval "$cmd" > "$temp_output" 2>&1; then 210 | local status="passed" 211 | local color="$GREEN" 212 | else 213 | local status="failed" 214 | local color="$RED" 215 | check_failed=1 216 | GLOBAL_ERROR=1 217 | fi 218 | 219 | # Calculate duration 220 | local end_time=$(date +%s) 221 | local duration=$((end_time - start_time)) 222 | 223 | # Display result 224 | echo -e "${color}${name} ${status} (${duration}s)${NO_COLOR}" 225 | 226 | # If verbose or failed, show output 227 | if [ "$VERBOSE" = true ] || [ "$status" = "failed" ]; then 228 | cat "$temp_output" 229 | fi 230 | 231 | # Log output 232 | { 233 | echo "=== ${name} ===" 234 | echo "Status: ${status}" 235 | echo "Duration: ${duration}s" 236 | echo "Command: ${cmd}" 237 | echo "Output:" 238 | cat "$temp_output" 239 | echo -e "================\n" 240 | } >> "$ERROR_LOG" 241 | 242 | rm "$temp_output" 243 | 244 | # Return success/failure without using $(...) 245 | if [ $check_failed -eq 1 ]; then 246 | return 1 247 | else 248 | return 0 249 | fi 250 | } 251 | 252 | # Function to setup virtual environment 253 | setup_venv() { 254 | print_section "Setting up virtual environment" 255 | 256 | # Check if venv exists and requirements are up to date 257 | if [ -f "${VENV_DIR}/pyvenv.cfg" ]; then 258 | local venv_creation_time=$(stat -c %Y "${VENV_DIR}/pyvenv.cfg" 2>/dev/null || stat -f %m "${VENV_DIR}/pyvenv.cfg") 259 | local requirements_mod_time=$(stat -c %Y "${REQUIREMENTS_FILE}" 2>/dev/null || stat -f %m "${REQUIREMENTS_FILE}") 260 | 261 | if [ "$venv_creation_time" -gt "$requirements_mod_time" ]; then 262 | echo -e "${GREEN}Virtual environment is up to date${NO_COLOR}" 263 | return 0 264 | fi 265 | fi 266 | 267 | echo "Creating/updating virtual environment..." 268 | check_command uv 269 | 270 | # Remove existing venv if it exists 271 | rm -rf "${VENV_DIR}" 272 | 273 | # Create new venv and install dependencies 274 | uv venv "${VENV_DIR}" 275 | source "${VENV_DIR}/bin/activate" 276 | uv pip install -e "..[dev]" 277 | } 278 | 279 | # Function to run parallel checks with proper error handling 280 | run_parallel_checks() { 281 | local -a pids=() 282 | local -A commands=( 283 | ["Black formatting"]="black ${SRC_DIR} $([ "$FIX_MODE" = true ] && echo '--quiet' || echo '--check --verbose')" 284 | ["Import sorting"]="isort ${SRC_DIR} $([ "$FIX_MODE" = true ] && echo '--quiet' || echo '--check-only --verbose --diff')" 285 | ["Flake8 linting"]="flake8 ${SRC_DIR}" 286 | ["MyPy type checking"]="mypy ${SRC_DIR} --config-file ../mypy.ini" 287 | ["Bandit security check"]="bandit -r ${SRC_DIR}" 288 | ) 289 | 290 | for name in "${!commands[@]}"; do 291 | (run_check "$name" "${commands[$name]}") & 292 | pids+=($!) 293 | done 294 | 295 | # Wait for all background processes 296 | for pid in "${pids[@]}"; do 297 | wait $pid || true # Don't exit if a check fails 298 | done 299 | } 300 | 301 | # Initialize results file with markdown formatting 302 | { 303 | echo "# Code Quality Check Results" 304 | echo "Run on: $(date)" 305 | echo "Mode: $([ "$FIX_MODE" = true ] && echo 'Fix' || echo 'Check')" 306 | echo "Environment: $([ "$CI_MODE" = true ] && echo 'CI' || echo 'Local')" 307 | echo "" 308 | echo '```text' 309 | } > "$RESULTS_FILE" 310 | 311 | # Setup virtual environment if needed 312 | if [ "$SKIP_VENV" = false ] && [ -z "${VIRTUAL_ENV:-}" ]; then 313 | setup_venv 314 | fi 315 | 316 | # Run checks 317 | if [ "$PARALLEL_MODE" = true ]; then 318 | run_parallel_checks 319 | else 320 | # Format checks 321 | if [ "$RUN_FORMAT" = true ]; then 322 | if [ "$FIX_MODE" = true ]; then 323 | run_check "Black formatting" "black ${SRC_DIR} --quiet" || true 324 | run_check "Import sorting" "isort ${SRC_DIR} --quiet" || true 325 | else 326 | run_check "Black formatting" "black ${SRC_DIR} --check --verbose" || true 327 | run_check "Import sorting" "isort ${SRC_DIR} --check-only --verbose --diff" || true 328 | fi 329 | fi 330 | 331 | # Lint checks 332 | if [ "$RUN_LINT" = true ]; then 333 | run_check "Flake8 linting" "flake8 ${SRC_DIR}" || true 334 | fi 335 | 336 | # Type checks 337 | if [ "$RUN_TYPE" = true ]; then 338 | run_check "MyPy type checking" "mypy ${SRC_DIR} --config-file ../mypy.ini" || true 339 | fi 340 | 341 | # Security checks 342 | if [ "$RUN_SECURITY" = true ]; then 343 | run_check "Bandit security check" "bandit -r ${SRC_DIR}" || true 344 | fi 345 | 346 | # Tests with coverage 347 | if [ "$RUN_TEST" = true ]; then 348 | if [ "$CI_MODE" = true ]; then 349 | run_check "Pytest with coverage" "pytest ../ --cov=${SRC_DIR} --cov-report=xml --cov-report=term-missing:skip-covered" || true 350 | else 351 | run_check "Pytest with coverage" "pytest ../ --cov=${SRC_DIR} --cov-report=term-missing:skip-covered" || true 352 | fi 353 | fi 354 | fi 355 | 356 | # Print summary 357 | print_section "Summary" 358 | if [ $GLOBAL_ERROR -eq 0 ]; then 359 | echo -e "${GREEN}✨ All checks passed! Ready for production.${NO_COLOR}" 360 | else 361 | echo -e "${RED}❌ Some checks failed. Please review the log for details.${NO_COLOR}" 362 | if [ "$FIX_MODE" = false ]; then 363 | echo -e "${YELLOW}💡 Tip: Try running with --fix to automatically fix formatting issues${NO_COLOR}" 364 | fi 365 | fi 366 | 367 | # Finalize markdown file 368 | echo '```' >> "$RESULTS_FILE" 369 | 370 | # Add error log if there are any errors 371 | if [ -s "$ERROR_LOG" ]; then 372 | { 373 | echo -e "\n## Detailed Error Log" 374 | echo '```text' 375 | cat "$ERROR_LOG" 376 | echo '```' 377 | } >> "$RESULTS_FILE" 378 | fi 379 | 380 | # Optional: Open the results file 381 | if [ "$CI_MODE" = false ]; then 382 | if command -v code >/dev/null 2>&1; then 383 | code "$RESULTS_FILE" "$ERROR_LOG" 384 | elif command -v open >/dev/null 2>&1; then 385 | open "$RESULTS_FILE" 386 | fi 387 | fi 388 | 389 | exit $GLOBAL_ERROR -------------------------------------------------------------------------------- /src/streamlit_analytics2/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main API functions for the user to start and stop analytics tracking. 3 | """ 4 | 5 | import datetime 6 | import json 7 | import logging 8 | from contextlib import contextmanager 9 | from pathlib import Path 10 | from typing import Any, Dict, Optional, Union 11 | 12 | import streamlit as st 13 | from streamlit import session_state as ss 14 | 15 | from . import config, display, firestore, utils, widgets # noqa: F811 F401 16 | from . import wrappers as _wrap 17 | from .state import data, reset_data 18 | 19 | # from streamlit_searchbox import st_searchbox 20 | 21 | # TODO look into https://github.com/444B/streamlit-analytics2/pull/119 to 22 | # integrate 23 | # logging.basicConfig( 24 | # level=logging.INFO, 25 | # format="streamlit-analytics2: %(levelname)s: %(message)s" 26 | # ) 27 | # Uncomment this during testing 28 | # logging.info("SA2: Streamlit-analytics2 successfully imported") 29 | 30 | 31 | def update_session_stats(data_dict: Dict[str, Any]): 32 | """ 33 | Update the session data with the current state. 34 | 35 | Parameters 36 | ---------- 37 | data : Dict[str, Any] 38 | Data, either aggregate or session-specific. 39 | 40 | Returns 41 | ------- 42 | Dict[str, Any] 43 | Updated data with the current state of time-dependent elements. 44 | """ 45 | today = str(datetime.date.today()) 46 | if data_dict["per_day"]["days"][-1] != today: 47 | # TODO: Insert 0 for all days between today and last entry. 48 | data_dict["per_day"]["days"].append(today) 49 | data_dict["per_day"]["pageviews"].append(0) 50 | data_dict["per_day"]["script_runs"].append(0) 51 | data_dict["total_script_runs"] += 1 52 | data_dict["per_day"]["script_runs"][-1] += 1 53 | now = datetime.datetime.now() 54 | data_dict["total_time_seconds"] += ( 55 | now - st.session_state.last_time 56 | ).total_seconds() 57 | st.session_state.last_time = now 58 | if not st.session_state.user_tracked: 59 | st.session_state.user_tracked = True 60 | data_dict["total_pageviews"] += 1 61 | data_dict["per_day"]["pageviews"][-1] += 1 62 | 63 | 64 | def _track_user(): 65 | """Track individual pageviews by storing user id to session state.""" 66 | update_session_stats(data) 67 | update_session_stats(ss.session_data) 68 | 69 | 70 | def start_tracking( 71 | unsafe_password: Optional[str] = None, 72 | save_to_json: Optional[Union[str, Path]] = None, 73 | load_from_json: Optional[Union[str, Path]] = None, 74 | firestore_project_name: Optional[str] = None, 75 | firestore_collection_name: Optional[str] = None, 76 | firestore_document_name: Optional[str] = "counts", 77 | firestore_key_file: Optional[str] = None, 78 | streamlit_secrets_firestore_key: Optional[str] = None, 79 | session_id: Optional[str] = None, 80 | verbose=False, 81 | ): 82 | """ 83 | Start tracking user inputs to a streamlit app. 84 | 85 | If you call this function directly, you NEED to call `streamlit_analytics. 86 | stop_tracking()` at the end of your streamlit script. For a more convenient 87 | interface, wrap your streamlit calls in `with streamlit_analytics.track():`. 88 | """ 89 | utils.initialize_session_data() 90 | 91 | if ( 92 | streamlit_secrets_firestore_key is not None 93 | and not data["loaded_from_firestore"] 94 | ): 95 | # Load both global and session data in a single call 96 | firestore.load( 97 | data=data, 98 | service_account_json=None, 99 | collection_name=firestore_collection_name, 100 | document_name=firestore_document_name, 101 | streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, 102 | firestore_project_name=firestore_project_name, 103 | session_id=session_id, # This will load global and session data 104 | ) 105 | data["loaded_from_firestore"] = True 106 | if verbose: 107 | print("Loaded count data from firestore:") 108 | print(data) 109 | if session_id: 110 | print("Loaded session count data from firestore:") 111 | print(ss.session_data) 112 | print() 113 | 114 | elif firestore_key_file and not data["loaded_from_firestore"]: 115 | firestore.load( 116 | data, 117 | firestore_key_file, 118 | firestore_collection_name, 119 | firestore_document_name, 120 | streamlit_secrets_firestore_key=None, 121 | firestore_project_name=None, 122 | session_id=session_id, 123 | ) 124 | data["loaded_from_firestore"] = True 125 | if verbose: 126 | print("Loaded count data from firestore:") 127 | print(data) 128 | print() 129 | 130 | if load_from_json is not None: 131 | log_msg_prefix = "Loading data from json: " 132 | try: 133 | # Using Path's read_text method simplifies file reading 134 | json_contents = Path(load_from_json).read_text() 135 | json_data = json.loads(json_contents) 136 | 137 | # Use dict.update() for a cleaner way to merge the data 138 | # This assumes you want json_data to overwrite existing keys in data 139 | data.update({k: json_data[k] for k in json_data if k in data}) 140 | 141 | if verbose: 142 | logging.info(f"{log_msg_prefix}{load_from_json}") 143 | logging.info("SA2: Success! Loaded data:") 144 | logging.info(data) 145 | 146 | except FileNotFoundError: 147 | if verbose: 148 | logging.warning(f"SA2: File {load_from_json} not found") 149 | logging.warning("Proceeding with empty data.") 150 | 151 | except Exception as e: 152 | # Catch-all for any other exceptions, log the error 153 | logging.error(f"SA2: Error loading data from {load_from_json}: {e}") 154 | 155 | # Reset session state. 156 | if "user_tracked" not in st.session_state: 157 | st.session_state.user_tracked = False 158 | if "state_dict" not in st.session_state: 159 | st.session_state.state_dict = {} 160 | if "last_time" not in st.session_state: 161 | st.session_state.last_time = datetime.datetime.now() 162 | _track_user() 163 | 164 | # widgets.monkey_patch() 165 | # Monkey-patch streamlit to call the wrappers above. 166 | st.button = _wrap.button(_orig_button) 167 | st.checkbox = _wrap.checkbox(_orig_checkbox) 168 | st.radio = _wrap.select(_orig_radio) 169 | st.selectbox = _wrap.select(_orig_selectbox) 170 | st.multiselect = _wrap.multiselect(_orig_multiselect) 171 | st.slider = _wrap.value(_orig_slider) 172 | st.select_slider = _wrap.select(_orig_select_slider) 173 | st.text_input = _wrap.value(_orig_text_input) 174 | st.number_input = _wrap.value(_orig_number_input) 175 | st.text_area = _wrap.value(_orig_text_area) 176 | st.date_input = _wrap.value(_orig_date_input) 177 | st.time_input = _wrap.value(_orig_time_input) 178 | st.file_uploader = _wrap.file_uploader(_orig_file_uploader) 179 | st.color_picker = _wrap.value(_orig_color_picker) 180 | # new elements, testing 181 | # st.download_button = _wrap.value(_orig_download_button) 182 | # st.link_button = _wrap.value(_orig_link_button) 183 | # st.page_link = _wrap.value(_orig_page_link) 184 | # st.toggle = _wrap.value(_orig_toggle) 185 | # st.camera_input = _wrap.value(_orig_camera_input) 186 | st.chat_input = _wrap.chat_input(_orig_chat_input) 187 | # st_searchbox = _wrap.searchbox(_orig_searchbox) 188 | 189 | st.sidebar.button = _wrap.button(_orig_sidebar_button) # type: ignore 190 | st.sidebar.radio = _wrap.select(_orig_sidebar_radio) # type: ignore 191 | st.sidebar.selectbox = _wrap.select(_orig_sidebar_selectbox) # type: ignore 192 | st.sidebar.multiselect = _wrap.multiselect(_orig_sidebar_multiselect) # type: ignore 193 | st.sidebar.slider = _wrap.value(_orig_sidebar_slider) # type: ignore 194 | st.sidebar.select_slider = _wrap.select(_orig_sidebar_select_slider) # type: ignore 195 | st.sidebar.text_input = _wrap.value(_orig_sidebar_text_input) # type: ignore 196 | st.sidebar.number_input = _wrap.value(_orig_sidebar_number_input) # type: ignore 197 | st.sidebar.text_area = _wrap.value(_orig_sidebar_text_area) # type: ignore 198 | st.sidebar.date_input = _wrap.value(_orig_sidebar_date_input) # type: ignore 199 | st.sidebar.time_input = _wrap.value(_orig_sidebar_time_input) # type: ignore 200 | st.sidebar.file_uploader = _wrap.file_uploader(_orig_sidebar_file_uploader) # type: ignore 201 | st.sidebar.color_picker = _wrap.value(_orig_sidebar_color_picker) # type: ignore 202 | # st.sidebar.st_searchbox = _wrap.searchbox(_orig_sidebar_searchbox) 203 | 204 | # new elements, testing 205 | # st.sidebar.download_button = _wrap.value(_orig_sidebar_download_button) 206 | # st.sidebar.link_button = _wrap.value(_orig_sidebar_link_button) 207 | # st.sidebar.page_link = _wrap.value(_orig_sidebar_page_link) 208 | # st.sidebar.toggle = _wrap.value(_orig_sidebar_toggle) 209 | # st.sidebar.camera_input = _wrap.value(_orig_sidebar_camera_input) 210 | 211 | # replacements = { 212 | # "button": _wrap.bool, 213 | # "checkbox": _wrap.bool, 214 | # "radio": _wrap.select, 215 | # "selectbox": _wrap.select, 216 | # "multiselect": _wrap.multiselect, 217 | # "slider": _wrap.value, 218 | # "select_slider": _wrap.select, 219 | # "text_input": _wrap.value, 220 | # "number_input": _wrap.value, 221 | # "text_area": _wrap.value, 222 | # "date_input": _wrap.value, 223 | # "time_input": _wrap.value, 224 | # "file_uploader": _wrap.file_uploader, 225 | # "color_picker": _wrap.value, 226 | # } 227 | 228 | if verbose: 229 | logging.info("\nSA2: streamlit-analytics2 verbose logging") 230 | 231 | 232 | def stop_tracking( 233 | unsafe_password: Optional[str] = None, 234 | save_to_json: Optional[Union[str, Path]] = None, 235 | load_from_json: Optional[Union[str, Path]] = None, 236 | firestore_project_name: Optional[str] = None, 237 | firestore_collection_name: Optional[str] = None, 238 | firestore_document_name: Optional[str] = "counts", 239 | firestore_key_file: Optional[str] = None, 240 | streamlit_secrets_firestore_key: Optional[str] = None, 241 | session_id: Optional[str] = None, 242 | verbose=False, 243 | ): 244 | """ 245 | Stop tracking user inputs to a streamlit app. 246 | 247 | Should be called after `streamlit-analytics.start_tracking()`. 248 | This method also shows the analytics results below your app if you attach 249 | `?analytics=on` to the URL. 250 | """ 251 | 252 | if verbose: 253 | logging.info("SA2: Finished script execution. New data:") 254 | logging.info( 255 | "%s", data 256 | ) # Use %s and pass data to logging to handle complex objects 257 | logging.info("%s", "-" * 80) # For separators or multi-line messages 258 | 259 | # widgets.reset_widgets() 260 | 261 | # Reset streamlit functions. 262 | st.button = _orig_button 263 | st.checkbox = _orig_checkbox 264 | st.radio = _orig_radio 265 | st.selectbox = _orig_selectbox 266 | st.multiselect = _orig_multiselect 267 | st.slider = _orig_slider 268 | st.select_slider = _orig_select_slider 269 | st.text_input = _orig_text_input 270 | st.number_input = _orig_number_input 271 | st.text_area = _orig_text_area 272 | st.date_input = _orig_date_input 273 | st.time_input = _orig_time_input 274 | st.file_uploader = _orig_file_uploader 275 | st.color_picker = _orig_color_picker 276 | # new elements, testing 277 | # st.download_button = _orig_download_button 278 | # st.link_button = _orig_link_button 279 | # st.page_link = _orig_page_link 280 | # st.toggle = _orig_toggle 281 | # st.camera_input = _orig_camera_input 282 | st.chat_input = _orig_chat_input 283 | # st.searchbox = _orig_searchbox 284 | st.sidebar.button = _orig_sidebar_button # type: ignore 285 | st.sidebar.checkbox = _orig_sidebar_checkbox # type: ignore 286 | st.sidebar.radio = _orig_sidebar_radio # type: ignore 287 | st.sidebar.selectbox = _orig_sidebar_selectbox # type: ignore 288 | st.sidebar.multiselect = _orig_sidebar_multiselect # type: ignore 289 | st.sidebar.slider = _orig_sidebar_slider # type: ignore 290 | st.sidebar.select_slider = _orig_sidebar_select_slider # type: ignore 291 | st.sidebar.text_input = _orig_sidebar_text_input # type: ignore 292 | st.sidebar.number_input = _orig_sidebar_number_input # type: ignore 293 | st.sidebar.text_area = _orig_sidebar_text_area # type: ignore 294 | st.sidebar.date_input = _orig_sidebar_date_input # type: ignore 295 | st.sidebar.time_input = _orig_sidebar_time_input # type: ignore 296 | st.sidebar.file_uploader = _orig_sidebar_file_uploader # type: ignore 297 | st.sidebar.color_picker = _orig_sidebar_color_picker # type: ignore 298 | # new elements, testing 299 | # st.sidebar.download_button = _orig_sidebar_download_button 300 | # st.sidebar.link_button = _orig_sidebar_link_button 301 | # st.sidebar.page_link = _orig_sidebar_page_link 302 | # st.sidebar.toggle = _orig_sidebar_toggle 303 | # st.sidebar.camera_input = _orig_sidebar_camera_input 304 | # st.sidebar.searchbox = _orig_sidebar_searchbox 305 | # Save count data to firestore. 306 | # TODO: Maybe don't save on every iteration but on regular intervals in a 307 | # background thread. 308 | 309 | if ( 310 | streamlit_secrets_firestore_key is not None 311 | and firestore_project_name is not None 312 | ): 313 | if verbose: 314 | print("Saving count data to firestore:") 315 | print(data) 316 | print("Saving session count data to firestore:") 317 | print(ss.session_data) 318 | print() 319 | 320 | # Save both global and session data in a single call 321 | firestore.save( 322 | data=data, 323 | service_account_json=None, 324 | collection_name=firestore_collection_name, 325 | document_name=firestore_document_name, 326 | streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, 327 | firestore_project_name=firestore_project_name, 328 | session_id=session_id, # This will save global and session data 329 | ) 330 | 331 | elif ( 332 | streamlit_secrets_firestore_key is None 333 | and firestore_project_name is None 334 | and firestore_key_file 335 | ): 336 | if verbose: 337 | print("Saving count data to firestore:") 338 | print(data) 339 | print() 340 | firestore.save( 341 | data, 342 | firestore_key_file, 343 | firestore_collection_name, 344 | firestore_document_name, 345 | streamlit_secrets_firestore_key=None, 346 | firestore_project_name=None, 347 | session_id=session_id, 348 | ) 349 | 350 | # Dump the data to json file if `save_to_json` is set. 351 | # TODO: Make sure this is not locked if writing from multiple threads. 352 | 353 | # Assuming 'data' is your data to be saved and 'save_to_json' is the path 354 | # to your json file. 355 | if save_to_json is not None: 356 | # Create a Path object for the file 357 | file_path = Path(save_to_json) 358 | 359 | # Ensure the directory containing the file exists 360 | file_path.parent.mkdir(parents=True, exist_ok=True) 361 | 362 | # Open the file and dump the json data 363 | with file_path.open("w") as f: 364 | json.dump(data, f) 365 | 366 | if verbose: 367 | print("Storing results to file:", save_to_json) 368 | 369 | # Show analytics results in the streamlit app if `?analytics=on` is set in 370 | # the URL. 371 | query_params = st.query_params 372 | if "analytics" in query_params and "on" in query_params["analytics"]: 373 | 374 | @st.dialog("Streamlit-Analytics2", width="large") 375 | def show_sa2(data, reset_data, unsafe_password): 376 | 377 | tab1, tab2 = st.tabs(["Data", "Config"]) 378 | 379 | with tab1: 380 | display.show_results(data, reset_data, unsafe_password) 381 | 382 | with tab2: 383 | config.show_config() 384 | 385 | show_sa2(data, reset_data, unsafe_password) 386 | 387 | 388 | @contextmanager 389 | def track( 390 | unsafe_password: Optional[str] = None, 391 | save_to_json: Optional[Union[str, Path]] = None, 392 | load_from_json: Optional[Union[str, Path]] = None, 393 | firestore_project_name: Optional[str] = None, 394 | firestore_collection_name: Optional[str] = None, 395 | firestore_document_name: Optional[str] = "counts", 396 | firestore_key_file: Optional[str] = None, 397 | streamlit_secrets_firestore_key: Optional[str] = None, 398 | session_id: Optional[str] = None, 399 | verbose=False, 400 | ): 401 | """ 402 | Context manager to start and stop tracking user inputs to a streamlit app. 403 | 404 | To use this, make calls to streamlit in `with streamlit_analytics.track():`. 405 | This also shows the analytics results below your app if you attach 406 | `?analytics=on` to the URL. 407 | """ 408 | if ( 409 | streamlit_secrets_firestore_key is not None 410 | and firestore_project_name is not None 411 | ): 412 | start_tracking( 413 | firestore_collection_name=firestore_collection_name, 414 | firestore_document_name=firestore_document_name, 415 | streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, 416 | firestore_project_name=firestore_project_name, 417 | session_id=session_id, 418 | verbose=verbose, 419 | ) 420 | 421 | else: 422 | start_tracking( 423 | firestore_key_file=firestore_key_file, 424 | firestore_collection_name=firestore_collection_name, 425 | firestore_document_name=firestore_document_name, 426 | load_from_json=load_from_json, 427 | session_id=session_id, 428 | verbose=verbose, 429 | ) 430 | # Yield here to execute the code in the with statement. This will call the 431 | # wrappers above, which track all inputs. 432 | yield 433 | if ( 434 | streamlit_secrets_firestore_key is not None 435 | and firestore_project_name is not None 436 | ): 437 | stop_tracking( 438 | unsafe_password=unsafe_password, 439 | firestore_collection_name=firestore_collection_name, 440 | firestore_document_name=firestore_document_name, 441 | streamlit_secrets_firestore_key=streamlit_secrets_firestore_key, 442 | firestore_project_name=firestore_project_name, 443 | session_id=session_id, 444 | verbose=verbose, 445 | ) 446 | else: 447 | stop_tracking( 448 | unsafe_password=unsafe_password, 449 | save_to_json=save_to_json, 450 | firestore_key_file=firestore_key_file, 451 | firestore_collection_name=firestore_collection_name, 452 | firestore_document_name=firestore_document_name, 453 | verbose=verbose, 454 | session_id=session_id, 455 | ) 456 | 457 | 458 | if __name__ == "streamlit_analytics2.main": 459 | reset_data() 460 | 461 | # widgets.copy_original() 462 | # TODO need to fix the scope for this function call and then we can move 463 | # these variable assignments to widgets.py 464 | 465 | # Store original streamlit functions. They will be monkey-patched with some 466 | # wrappers in `start_tracking` (see wrapper functions below). 467 | _orig_button = st.button 468 | _orig_checkbox = st.checkbox 469 | _orig_radio = st.radio 470 | _orig_selectbox = st.selectbox 471 | _orig_multiselect = st.multiselect 472 | _orig_slider = st.slider 473 | _orig_select_slider = st.select_slider 474 | _orig_text_input = st.text_input 475 | _orig_number_input = st.number_input 476 | _orig_text_area = st.text_area 477 | _orig_date_input = st.date_input 478 | _orig_time_input = st.time_input 479 | _orig_file_uploader = st.file_uploader 480 | _orig_color_picker = st.color_picker 481 | # new elements, testing 482 | # _orig_download_button = st.download_button 483 | # _orig_link_button = st.link_button 484 | # _orig_page_link = st.page_link 485 | # _orig_toggle = st.toggle 486 | # _orig_camera_input = st.camera_input 487 | _orig_chat_input = st.chat_input 488 | # _orig_searchbox = st_searchbox 489 | 490 | _orig_sidebar_button = st.sidebar.button 491 | _orig_sidebar_checkbox = st.sidebar.checkbox 492 | _orig_sidebar_radio = st.sidebar.radio 493 | _orig_sidebar_selectbox = st.sidebar.selectbox 494 | _orig_sidebar_multiselect = st.sidebar.multiselect 495 | _orig_sidebar_slider = st.sidebar.slider 496 | _orig_sidebar_select_slider = st.sidebar.select_slider 497 | _orig_sidebar_text_input = st.sidebar.text_input 498 | _orig_sidebar_number_input = st.sidebar.number_input 499 | _orig_sidebar_text_area = st.sidebar.text_area 500 | _orig_sidebar_date_input = st.sidebar.date_input 501 | _orig_sidebar_time_input = st.sidebar.time_input 502 | _orig_sidebar_file_uploader = st.sidebar.file_uploader 503 | _orig_sidebar_color_picker = st.sidebar.color_picker 504 | # _orig_sidebar_searchbox = st.sidebar.st_searchbox 505 | # new elements, testing 506 | # _orig_sidebar_download_button = st.sidebar.download_button 507 | # _orig_sidebar_link_button = st.sidebar.link_button 508 | # _orig_sidebar_page_link = st.sidebar.page_link 509 | # _orig_sidebar_toggle = st.sidebar.toggle 510 | # _orig_sidebar_camera_input = st.sidebar.camera_input 511 | --------------------------------------------------------------------------------