├── .github └── FUNDING.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── readme_resources └── events-example.jpg ├── ruff.toml └── umami ├── .gitignore ├── LICENSE ├── README.md ├── example_client ├── client.py └── settings-template.json ├── pyproject.toml ├── requirements.txt ├── tests └── test_sample.py ├── tox.ini └── umami ├── __init__.py ├── errors └── __init__.py ├── impl └── __init__.py ├── models └── __init__.py └── urls.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mikeckennedy] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | /.idea/ 162 | /umami/example_client/settings.json 163 | 164 | # ruff 165 | .ruff_cache/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/charliermarsh/ruff-pre-commit 3 | # Ruff version. 4 | rev: "v0.1.14" 5 | hooks: 6 | - id: ruff 7 | args: [--fix] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Michael Kennedy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Umami Analytics Client for Python 2 | 3 | Client for privacy-preserving, open source [Umami analytics platform](https://umami.is) based on 4 | `httpx` and `pydantic`. 5 | 6 | `umami-analytics` is intended for adding custom data to your Umami instance (self-hosted or SaaS). Many umami events can supplied directly from HTML via their `data-*` attributes. However, some cannot. For example, if you have an event that is triggered in your app but doesn't have a clear HTML action you can add custom events. These will appear at the bottom of your Umami analtytics page for a website. 7 | 8 | One example is a **purchase-course** event that happens deep inside the Python code rather than in HTML at [Talk Python Training](https://training.talkpython.fm). This is what our events section looks like for a typical weekend day (US Pacific Time): 9 | 10 | ![](https://raw.githubusercontent.com/mikeckennedy/umami-python/main/readme_resources/events-example.jpg) 11 | 12 | ## Focused on what you need, not what is offered 13 | 14 | The [Umami API is extensive](https://umami.is/docs/api) and much of that is intended for their frontend code to be able to function. You probably don't want or need that. `umami-analytics` only covers the subset that most developers will need for common SaaS actions such as adding [custom events](https://umami.is/docs/event-data). That said, PRs are weclome. 15 | 16 | ## Core Features 17 | 18 | * ➕ **Add a custom event** to your Umami analytics dashboard. 19 | * 📄 **Add a page view** to your Umami analytics dashboard. 20 | * 🌐 List all websites with details that you have registered at Umami. 21 | * 📊 **Get website statistics** including page views, visitors, bounce rate, and more. 22 | * 👥 **Get active users** count for real-time monitoring. 23 | * 💓 **Heartbeat check** to verify Umami server connectivity. 24 | * 🔀 Both **sync** and **async** programming models. 25 | * ⚒️ **Structured data with Pydantic** models for API responses. 26 | * 👩‍💻 **Login / authenticate** for either a self-hosted or SaaS hosted instance of Umami. 27 | * 🥇Set a **default website** for a **simplified API** going forward. 28 | * 🔧 **Enable/disable tracking** for development and testing environments. 29 | 30 | ## Development and Testing Support 31 | 32 | 🔧 **Disable tracking in development**: Use `umami.disable()` to disable all event and page view tracking without changing your code. Perfect for development and testing environments where you don't want to pollute your analytics with test data. 33 | 34 | ```python 35 | import umami 36 | 37 | # Configure as usual 38 | umami.set_url_base("https://umami.hostedbyyouorthem.com") 39 | umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') 40 | umami.set_hostname('somedomain.com') 41 | 42 | # Disable tracking for development/testing 43 | umami.disable() 44 | 45 | # These calls will return immediately without sending data to Umami 46 | umami.new_event('test-event') # No HTTP request made 47 | umami.new_page_view('Test Page', '/test') # No HTTP request made 48 | 49 | # Re-enable when needed (default state is enabled) 50 | umami.enable() 51 | ``` 52 | 53 | When tracking is disabled: 54 | - ✅ **No HTTP requests** are made to your Umami server 55 | - ✅ **API calls still validate** parameters (helps catch configuration issues) 56 | - ✅ **All other functions work normally** (login, websites, stats, etc.) 57 | - ✅ **Functions return appropriate values** for compatibility 58 | 59 | See the usage example below for the Python API around these features. 60 | 61 | ## Async or sync API? You choose 62 | 63 | 🔀 **Async is supported but not required** for your Python code. For functions that access the network, there is a `func()` and `func_async()` variant that works with Python's `async` and `await`. 64 | 65 | ## Installation 66 | 67 | Just `pip install umami-analytics` 68 | 69 | ## Usage 70 | 71 | ```python 72 | 73 | import umami 74 | 75 | umami.set_url_base("https://umami.hostedbyyouorthem.com") 76 | 77 | # Auth is NOT required to send events, but is for other features. 78 | login = umami.login(username, password) 79 | 80 | # Skip the need to pass the target website in subsequent calls. 81 | umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') 82 | umami.set_hostname('somedomain.com') 83 | 84 | # Optional: Disable tracking for development/testing 85 | # umami.disable() # Uncomment to disable tracking 86 | 87 | # List your websites 88 | websites = umami.websites() 89 | 90 | # Create a new event in the events section of the dashboards. 91 | event_resp = umami.new_event( 92 | website_id='a7cd-5d1a-2b33', # Only send if overriding default above 93 | event_name='Umami-Test', 94 | title='Umami-Test', # Defaults to event_name if omitted. 95 | hostname='somedomain.com', # Only send if overriding default above. 96 | url='/users/actions', 97 | custom_data={'client': 'umami-tester-v1'}, 98 | referrer='https://some_url') 99 | 100 | # Create a new page view in the pages section of the dashboards. 101 | page_view_resp = umami.new_page_view( 102 | website_id='a7cd-5d1a-2b33', # Only send if overriding default above 103 | page_title='Umami-Test', # Defaults to event_name if omitted. 104 | hostname='somedomain.com', # Only send if overriding default above. 105 | url='/users/actions', 106 | referrer='https://some_url') 107 | 108 | # Get website statistics for a date range 109 | from datetime import datetime, timedelta 110 | 111 | end_date = datetime.now() 112 | start_date = end_date - timedelta(days=7) # Last 7 days 113 | 114 | stats = umami.website_stats( 115 | start_at=start_date, 116 | end_at=end_date, 117 | website_id='a7cd-5d1a-2b33' # Only send if overriding default above 118 | ) 119 | print(f"Page views: {stats.pageviews}") 120 | print(f"Unique visitors: {stats.visitors}") 121 | print(f"Bounce rate: {stats.bounces}") 122 | 123 | # Get current active users count 124 | active_count = umami.active_users( 125 | website_id='a7cd-5d1a-2b33' # Only send if overriding default above 126 | ) 127 | print(f"Currently active users: {active_count}") 128 | 129 | # Check if Umami server is accessible 130 | server_ok = umami.heartbeat() 131 | print(f"Umami server is {'accessible' if server_ok else 'not accessible'}") 132 | 133 | # Call after logging in to make sure the auth token is still valid. 134 | umami.verify_token() 135 | ``` 136 | 137 | This code listing is very-very high fidelity psuedo code. If you want an actually executable example, see the [example client](./umami/example_client) in the repo. 138 | 139 | ## Want to contribute? 140 | 141 | See the [API documentation](https://umami.is/docs/api) for the remaining endpoints to be added. PRs are welcome. But please open an issue first to see if the proposed feature fits with the direction of this library. 142 | 143 | Enjoy. -------------------------------------------------------------------------------- /readme_resources/events-example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikeckennedy/umami-python/acf41676a9c9d4b83dfc98c9b751a343b3180ed2/readme_resources/events-example.jpg -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # [ruff] 2 | line-length = 120 3 | format.quote-style = "single" 4 | 5 | # Enable Pyflakes `E` and `F` codes by default. 6 | lint.select = ["E", "F"] 7 | lint.ignore = [ 8 | "E501" # Line length 9 | ] 10 | 11 | # Exclude a variety of commonly ignored directories. 12 | exclude = [ 13 | ".bzr", 14 | ".direnv", 15 | ".eggs", 16 | ".egg-info", 17 | ".git", 18 | ".hg", 19 | ".mypy_cache", 20 | ".nox", 21 | ".pants.d", 22 | ".ruff_cache", 23 | ".svn", 24 | ".tox", 25 | "__pypackages__", 26 | "_build", 27 | "buck-out", 28 | "build", 29 | "dist", 30 | "node_modules", 31 | ".env", 32 | ".venv", 33 | "venv", 34 | ] 35 | lint.per-file-ignores = {} 36 | 37 | # Allow unused variables when underscore-prefixed. 38 | # dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 39 | 40 | # Assume Python 3.8. 41 | target-version = "py38" 42 | 43 | #[tool.ruff.mccabe] 44 | ## Unlike Flake8, default to a complexity level of 10. 45 | lint.mccabe.max-complexity = 10 46 | -------------------------------------------------------------------------------- /umami/.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /umami/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Michael Kennedy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /umami/README.md: -------------------------------------------------------------------------------- 1 | # Umami Analytics Client for Python 2 | 3 | Client for privacy-preserving, open source [Umami analytics platform](https://umami.is) based on 4 | `httpx` and `pydantic`. 5 | 6 | `umami-analytics` is intended for adding custom data to your Umami instance (self-hosted or SaaS). Many umami events can supplied directly from HTML via their `data-*` attributes. However, some cannot. For example, if you have an event that is triggered in your app but doesn't have a clear HTML action you can add custom events. These will appear at the bottom of your Umami analtytics page for a website. 7 | 8 | One example is a **purchase-course** event that happens deep inside the Python code rather than in HTML at [Talk Python Training](https://training.talkpython.fm). This is what our events section looks like for a typical weekend day (US Pacific Time): 9 | 10 | ![](https://raw.githubusercontent.com/mikeckennedy/umami-python/main/readme_resources/events-example.jpg) 11 | 12 | ## Focused on what you need, not what is offered 13 | 14 | The [Umami API is extensive](https://umami.is/docs/api) and much of that is intended for their frontend code to be able to function. You probably don't want or need that. `umami-analytics` only covers the subset that most developers will need for common SaaS actions such as adding [custom events](https://umami.is/docs/event-data). That said, PRs are weclome. 15 | 16 | ## Core Features 17 | 18 | * ➕ **Add a custom event** to your Umami analytics dashboard. 19 | * 📄 **Add a page view** to your Umami analytics dashboard. 20 | * 🌐 List all websites with details that you have registered at Umami. 21 | * 📊 **Get website statistics** including page views, visitors, bounce rate, and more. 22 | * 👥 **Get active users** count for real-time monitoring. 23 | * 💓 **Heartbeat check** to verify Umami server connectivity. 24 | * 🔀 Both **sync** and **async** programming models. 25 | * ⚒️ **Structured data with Pydantic** models for API responses. 26 | * 👩‍💻 **Login / authenticate** for either a self-hosted or SaaS hosted instance of Umami. 27 | * 🥇Set a **default website** for a **simplified API** going forward. 28 | * 🔧 **Enable/disable tracking** for development and testing environments. 29 | 30 | ## Development and Testing Support 31 | 32 | 🔧 **Disable tracking in development**: Use `umami.disable()` to disable all event and page view tracking without changing your code. Perfect for development and testing environments where you don't want to pollute your analytics with test data. 33 | 34 | ```python 35 | import umami 36 | 37 | # Configure as usual 38 | umami.set_url_base("https://umami.hostedbyyouorthem.com") 39 | umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') 40 | umami.set_hostname('somedomain.com') 41 | 42 | # Disable tracking for development/testing 43 | umami.disable() 44 | 45 | # These calls will return immediately without sending data to Umami 46 | umami.new_event('test-event') # No HTTP request made 47 | umami.new_page_view('Test Page', '/test') # No HTTP request made 48 | 49 | # Re-enable when needed (default state is enabled) 50 | umami.enable() 51 | ``` 52 | 53 | When tracking is disabled: 54 | - ✅ **No HTTP requests** are made to your Umami server 55 | - ✅ **API calls still validate** parameters (helps catch configuration issues) 56 | - ✅ **All other functions work normally** (login, websites, stats, etc.) 57 | - ✅ **Functions return appropriate values** for compatibility 58 | 59 | See the usage example below for the Python API around these features. 60 | 61 | ## Async or sync API? You choose 62 | 63 | 🔀 **Async is supported but not required** for your Python code. For functions that access the network, there is a `func()` and `func_async()` variant that works with Python's `async` and `await`. 64 | 65 | ## Installation 66 | 67 | Just `pip install umami-analytics` 68 | 69 | ## Usage 70 | 71 | ```python 72 | 73 | import umami 74 | 75 | umami.set_url_base("https://umami.hostedbyyouorthem.com") 76 | 77 | # Auth is NOT required to send events, but is for other features. 78 | login = umami.login(username, password) 79 | 80 | # Skip the need to pass the target website in subsequent calls. 81 | umami.set_website_id('cc726914-8e68-4d1a-4be0-af4ca8933456') 82 | umami.set_hostname('somedomain.com') 83 | 84 | # Optional: Disable tracking for development/testing 85 | # umami.disable() # Uncomment to disable tracking 86 | 87 | # List your websites 88 | websites = umami.websites() 89 | 90 | # Create a new event in the events section of the dashboards. 91 | event_resp = umami.new_event( 92 | website_id='a7cd-5d1a-2b33', # Only send if overriding default above 93 | event_name='Umami-Test', 94 | title='Umami-Test', # Defaults to event_name if omitted. 95 | hostname='somedomain.com', # Only send if overriding default above. 96 | url='/users/actions', 97 | custom_data={'client': 'umami-tester-v1'}, 98 | referrer='https://some_url') 99 | 100 | # Create a new page view in the pages section of the dashboards. 101 | page_view_resp = umami.new_page_view( 102 | website_id='a7cd-5d1a-2b33', # Only send if overriding default above 103 | page_title='Umami-Test', # Defaults to event_name if omitted. 104 | hostname='somedomain.com', # Only send if overriding default above. 105 | url='/users/actions', 106 | referrer='https://some_url') 107 | 108 | # Get website statistics for a date range 109 | from datetime import datetime, timedelta 110 | 111 | end_date = datetime.now() 112 | start_date = end_date - timedelta(days=7) # Last 7 days 113 | 114 | stats = umami.website_stats( 115 | start_at=start_date, 116 | end_at=end_date, 117 | website_id='a7cd-5d1a-2b33' # Only send if overriding default above 118 | ) 119 | print(f"Page views: {stats.pageviews}") 120 | print(f"Unique visitors: {stats.visitors}") 121 | print(f"Bounce rate: {stats.bounces}") 122 | 123 | # Get current active users count 124 | active_count = umami.active_users( 125 | website_id='a7cd-5d1a-2b33' # Only send if overriding default above 126 | ) 127 | print(f"Currently active users: {active_count}") 128 | 129 | # Check if Umami server is accessible 130 | server_ok = umami.heartbeat() 131 | print(f"Umami server is {'accessible' if server_ok else 'not accessible'}") 132 | 133 | # Call after logging in to make sure the auth token is still valid. 134 | umami.verify_token() 135 | ``` 136 | 137 | This code listing is very-very high fidelity psuedo code. If you want an actually executable example, see the [example client](./umami/example_client) in the repo. 138 | 139 | ## Want to contribute? 140 | 141 | See the [API documentation](https://umami.is/docs/api) for the remaining endpoints to be added. PRs are welcome. But please open an issue first to see if the proposed feature fits with the direction of this library. 142 | 143 | Enjoy. -------------------------------------------------------------------------------- /umami/example_client/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Any 4 | 5 | import umami 6 | 7 | file = Path(__file__).parent / 'settings.json' 8 | 9 | settings: dict[str, Any] = {} 10 | if file.exists(): 11 | settings = json.loads(file.read_text()) 12 | 13 | print(umami.user_agent) 14 | 15 | url = settings.get('base_url') or input('Enter the base URL for your instance: ') 16 | user = settings.get('username') or input('Enter the username for Umami: ') 17 | password = settings.get('password') or input('Enter the password for ') 18 | 19 | umami.set_url_base(url) 20 | print(f'Not currently logged in? {not umami.is_logged_in()}') 21 | 22 | login = umami.login(user, password) 23 | print(f'Logged in successfully as {login.user.username} : admin? {login.user.isAdmin}') 24 | print(f'Currently logged in? {umami.is_logged_in()}') 25 | print() 26 | 27 | print('Verify token:') 28 | print(umami.verify_token(check_server=False)) 29 | print(umami.verify_token()) 30 | print() 31 | 32 | print('Checking heartbeat') 33 | print(umami.heartbeat()) 34 | print() 35 | 36 | websites = umami.websites() 37 | print(f'Found {len(websites):,} websites.') 38 | print('First website in list:') 39 | print(websites[0]) 40 | print() 41 | 42 | if test_domain := settings.get('test_domain'): 43 | test_site = [w for w in websites if w.domain == test_domain][0] 44 | print(f'Using {test_domain} for testing events.') 45 | 46 | # Set these once 47 | umami.set_hostname(test_site.domain) 48 | umami.set_website_id(test_site.id) 49 | 50 | # Demonstrate the new enable/disable functionality 51 | print('\n=== Demonstrating tracking enable/disable functionality ===') 52 | 53 | # Test with tracking enabled (default) 54 | print('1. Sending event with tracking enabled...') 55 | umami.new_event( 56 | event_name='Umami-Test-Event3', 57 | title='Umami-Test-Event3', 58 | url='/users/actions', 59 | custom_data={'client': 'umami-tester-v1'}, 60 | referrer='https://talkpython.fm', 61 | ) 62 | print(' ✓ Event sent to Umami') 63 | 64 | # Test with tracking disabled 65 | print('2. Disabling tracking...') 66 | umami.disable() 67 | print(' Tracking disabled. Now sending event (should not reach Umami)...') 68 | 69 | umami.new_event( 70 | event_name='This-Should-Not-Be-Sent', 71 | title='This event should not appear in Umami', 72 | url='/disabled-test', 73 | custom_data={'should_not_appear': True}, 74 | ) 75 | print(' ✓ Event call completed but no data sent to Umami') 76 | 77 | # Test page view with tracking disabled 78 | print(' Sending page view with tracking disabled...') 79 | umami.new_page_view('Disabled Test Page', '/disabled-page-view', ip_address='127.100.200.1') 80 | print(' ✓ Page view call completed but no data sent to Umami') 81 | 82 | # Re-enable tracking 83 | print('3. Re-enabling tracking...') 84 | umami.enable() 85 | print(' Tracking re-enabled. Sending final test event...') 86 | 87 | umami.new_event( 88 | event_name='Tracking-Re-Enabled', 89 | title='This event should appear in Umami', 90 | url='/re-enabled-test', 91 | custom_data={'tracking_restored': True}, 92 | ) 93 | print(' ✓ Event sent to Umami') 94 | 95 | print('\nSending event as if we are a browser user') 96 | umami.new_page_view('Account Details - Your App', '/account/details', ip_address='127.100.200.1') 97 | else: 98 | print('No test domain, skipping event creation.') 99 | 100 | print('\n=== Summary ===') 101 | print('The new tracking control functions allow you to:') 102 | print('• umami.disable() - Disable tracking for dev/test environments') 103 | print('• umami.enable() - Re-enable tracking (default state)') 104 | print('• All API calls still work and validate parameters when disabled') 105 | print('• No HTTP requests are made to Umami when tracking is disabled') 106 | -------------------------------------------------------------------------------- /umami/example_client/settings-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "base_url": "Where your instance hosted? e.g. https://analytics.yoursite.com", 3 | "username": "user_at_umami_instance", 4 | "password": "password_at_umami_instance", 5 | "test_domain": "domain name of the site you created for testing (will create events here)", 6 | "ACTION": "COPY TO settings.json (excluded from git) and enter your info THERE (NOT HERE)" 7 | } -------------------------------------------------------------------------------- /umami/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "umami-analytics" 3 | description = "Umami Analytics Client for Python" 4 | readme = "README.md" 5 | license = "MIT" 6 | requires-python = ">=3.9" 7 | keywords = [ 8 | "analytics", 9 | "website", 10 | "umami", 11 | ] 12 | authors = [ 13 | { name = "Michael Kennedy", email = "michael@talkpython.fm" }, 14 | ] 15 | classifiers = [ 16 | 'Development Status :: 4 - Beta', 17 | 'License :: OSI Approved :: MIT License', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3', 20 | 'Programming Language :: Python :: 3.8', 21 | 'Programming Language :: Python :: 3.9', 22 | 'Programming Language :: Python :: 3.10', 23 | 'Programming Language :: Python :: 3.11', 24 | 'Programming Language :: Python :: 3.12', 25 | 'Programming Language :: Python :: 3.13', 26 | 'Programming Language :: Python :: 3.14', 27 | ] 28 | dependencies = [ 29 | "httpx", 30 | "pydantic", 31 | ] 32 | version = "0.2.20" 33 | 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/mikeckennedy/umami-python" 37 | Tracker = "https://github.com/mikeckennedy/umami-python/issues" 38 | Source = "https://github.com/mikeckennedy/umami-python" 39 | 40 | [build-system] 41 | requires = ["hatchling>=1.21.0", "hatch-vcs>=0.3.0"] 42 | build-backend = "hatchling.build" 43 | 44 | 45 | [tool.hatch.build.targets.sdist] 46 | exclude = [ 47 | "/.github", 48 | "/tests", 49 | "/example_client", 50 | "setup.py", 51 | "settings.json", 52 | "/tox.ini", 53 | ] 54 | 55 | [tool.hatch.build.targets.wheel] 56 | packages = ["umami"] 57 | exclude = [ 58 | "/.github", 59 | "/tests", 60 | "/example_client", 61 | "setup.py", 62 | "settings.json", 63 | "/tox.ini", 64 | ] -------------------------------------------------------------------------------- /umami/requirements.txt: -------------------------------------------------------------------------------- 1 | httpx 2 | pydantic -------------------------------------------------------------------------------- /umami/tests/test_sample.py: -------------------------------------------------------------------------------- 1 | # Sample Test passing with nose and pytest 2 | 3 | 4 | def test_pass(): 5 | assert True, 'dummy sample test' 6 | -------------------------------------------------------------------------------- /umami/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34,py35,py36,py37 3 | 4 | [testenv] 5 | commands = py.test umami 6 | deps = pytest 7 | -------------------------------------------------------------------------------- /umami/umami/__init__.py: -------------------------------------------------------------------------------- 1 | from umami import impl 2 | from . import errors # noqa: F401, E402 3 | from . import models # noqa: F401, E402 4 | from .impl import active_users, active_users_async # noqa: F401, E402 5 | from .impl import heartbeat_async, heartbeat # noqa: F401, E402 6 | from .impl import login_async, login, is_logged_in # noqa: F401, E402 7 | from .impl import new_event_async, new_event # noqa: F401, E402 8 | from .impl import new_page_view, new_page_view_async # noqa: F401, E402 9 | from .impl import set_url_base, set_website_id, set_hostname # noqa: F401, E402 10 | from .impl import verify_token_async, verify_token # noqa: F401, E402 11 | from .impl import website_stats, website_stats_async # noqa: F401, E402 12 | from .impl import websites_async, websites # noqa: F401, E402 13 | from .impl import enable, disable # noqa: F401, E402 14 | 15 | __author__ = 'Michael Kennedy ' 16 | __version__ = impl.__version__ 17 | user_agent = impl.user_agent 18 | 19 | # fmt: off 20 | # ruff: noqa 21 | __all__ = [ 22 | # Core modules 23 | 'models', 24 | 'errors', 25 | 26 | # Configuration/Setup 27 | 'set_url_base', 28 | 'set_website_id', 29 | 'set_hostname', 30 | 'enable', 31 | 'disable', 32 | 33 | # Authentication 34 | 'login', 35 | 'login_async', 36 | 'is_logged_in', 37 | 'verify_token', 38 | 'verify_token_async', 39 | 40 | # Basic operations 41 | 'websites', 42 | 'websites_async', 43 | 'heartbeat', 44 | 'heartbeat_async', 45 | 46 | # Main features - Events and Analytics 47 | 'new_event', 48 | 'new_event_async', 49 | 'new_page_view', 50 | 'new_page_view_async', 51 | 'website_stats', 52 | 'website_stats_async', 53 | 'active_users', 54 | 'active_users_async', 55 | ] 56 | # fmt: on 57 | -------------------------------------------------------------------------------- /umami/umami/errors/__init__.py: -------------------------------------------------------------------------------- 1 | class ValidationError(Exception): 2 | pass 3 | 4 | 5 | class OperationNotAllowedError(ValidationError): 6 | pass 7 | -------------------------------------------------------------------------------- /umami/umami/impl/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import sys 4 | from typing import Optional, Any, Dict 5 | 6 | import httpx 7 | 8 | from umami import models, urls 9 | 10 | __version__ = '0.2.20' 11 | 12 | from umami.errors import ValidationError, OperationNotAllowedError 13 | from datetime import datetime 14 | 15 | url_base: Optional[str] = None 16 | auth_token: Optional[str] = None 17 | default_website_id: Optional[str] = None 18 | default_hostname: Optional[str] = None 19 | tracking_enabled: bool = True 20 | # An actual browser UA is needed to get around the bot detection in Umami 21 | # You can also set DISABLE_BOT_CHECK=true in your Umami environment to disable the bot check entirely: 22 | # https://github.com/umami-software/umami/blob/7a3443cd06772f3cde37bdbb0bf38eabf4515561/pages/api/collect.js#L13 23 | event_user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0' 24 | user_agent = ( 25 | f'Umami-Client v{__version__} / ' 26 | f'Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} / ' 27 | f'{sys.platform.capitalize()}' 28 | ) 29 | 30 | 31 | def set_url_base(url: str) -> None: 32 | """ 33 | Each Umami instance lives somewhere. This is where yours lives. 34 | For example, https://somedomain.tech/umami. 35 | Args: 36 | url: The base URL of your instance without /api. 37 | """ 38 | if not url or not url.strip(): 39 | raise ValidationError('URL must not be empty.') 40 | 41 | # noinspection HttpUrlsUsage 42 | if not url.startswith('http://') and not url.startswith('https://'): 43 | # noinspection HttpUrlsUsage 44 | raise ValidationError('The url must start with the HTTP scheme (http:// or https://).') 45 | 46 | if url.endswith('/'): 47 | url = url.rstrip('/') 48 | 49 | global url_base 50 | url_base = url.strip() 51 | 52 | 53 | def set_website_id(website_id: str) -> None: 54 | """ 55 | Your Umami instance might have many websites registered for various domains you use. 56 | Call this function to set which website you're working with. 57 | Args: 58 | website_id: The ID from Umami for your registered site (e.g. 978435e2-7ba1-4337-9860-ec31ece2db60) 59 | """ 60 | global default_website_id 61 | default_website_id = website_id 62 | 63 | 64 | def set_hostname(hostname: str) -> None: 65 | """ 66 | The default hostname for sending events (can be overriden in the new_event() function). 67 | Args: 68 | hostname: Hostname to use when one is not specified, e.g. 'talkpython.fm' 69 | """ 70 | global default_hostname 71 | default_hostname = hostname 72 | 73 | 74 | def is_logged_in() -> bool: 75 | return auth_token is not None 76 | 77 | 78 | async def login_async(username: str, password: str) -> models.LoginResponse: 79 | """ 80 | Logs into Umami and retrieves a temporary auth token. If the token is expired, 81 | you'll need to log in again. This can be checked with verify_token(). 82 | Args: 83 | username: Your Umami username 84 | password: Your Umami password 85 | 86 | Returns: LoginResponse object which your token and user details (no need to save this). 87 | """ 88 | global auth_token 89 | validate_state(url=True) 90 | validate_login(username, password) 91 | 92 | url = f'{url_base}{urls.login}' 93 | headers = {'User-Agent': user_agent} 94 | api_data = { 95 | 'username': username, 96 | 'password': password, 97 | } 98 | async with httpx.AsyncClient() as client: 99 | resp = await client.post(url, json=api_data, headers=headers, follow_redirects=True) 100 | resp.raise_for_status() 101 | 102 | model = models.LoginResponse(**resp.json()) 103 | auth_token = model.token 104 | return model 105 | 106 | 107 | def login(username: str, password: str) -> models.LoginResponse: 108 | """ 109 | Logs into Umami and retrieves a temporary auth token. If the token is expired, 110 | you'll need to log in again. This can be checked with verify_token(). 111 | Args: 112 | username: Your Umami username 113 | password: Your Umami password 114 | 115 | Returns: LoginResponse object which your token and user details (no need to save this). 116 | """ 117 | global auth_token 118 | 119 | validate_state(url=True) 120 | validate_login(username, password) 121 | 122 | url = f'{url_base}{urls.login}' 123 | headers = {'User-Agent': user_agent} 124 | api_data = { 125 | 'username': username, 126 | 'password': password, 127 | } 128 | resp = httpx.post(url, json=api_data, headers=headers, follow_redirects=True) 129 | resp.raise_for_status() 130 | 131 | model = models.LoginResponse(**resp.json()) 132 | auth_token = model.token 133 | return model 134 | 135 | 136 | async def websites_async() -> list[models.Website]: 137 | """ 138 | All the websites that are registered in your Umami instance. 139 | Returns: A list of Website Pydantic models. 140 | """ 141 | global auth_token 142 | validate_state(url=True, user=True) 143 | 144 | url = f'{url_base}{urls.websites}' 145 | headers = { 146 | 'User-Agent': user_agent, 147 | 'Authorization': f'Bearer {auth_token}', 148 | } 149 | 150 | async with httpx.AsyncClient() as client: # type: ignore 151 | resp = await client.get(url, headers=headers, follow_redirects=True) 152 | resp.raise_for_status() 153 | 154 | model = models.WebsitesResponse(**resp.json()) 155 | return model.websites 156 | 157 | 158 | def websites() -> list[models.Website]: 159 | """ 160 | All the websites that are registered in your Umami instance. 161 | Returns: A list of Website Pydantic models. 162 | """ 163 | global auth_token 164 | validate_state(url=True, user=True) 165 | 166 | url = f'{url_base}{urls.websites}' 167 | headers = { 168 | 'User-Agent': user_agent, 169 | 'Authorization': f'Bearer {auth_token}', 170 | } 171 | resp = httpx.get(url, headers=headers, follow_redirects=True) 172 | resp.raise_for_status() 173 | 174 | data = resp.json() 175 | model = models.WebsitesResponse(**data) 176 | return model.websites 177 | 178 | 179 | def enable() -> None: 180 | """ 181 | Enable event and page view tracking. 182 | 183 | When enabled, new_event() and new_page_view() functions will send 184 | data to Umami normally. This is the default state. 185 | """ 186 | global tracking_enabled 187 | tracking_enabled = True 188 | 189 | 190 | def disable() -> None: 191 | """ 192 | Disable event and page view tracking. 193 | 194 | When disabled, new_event() and new_page_view() functions will return 195 | immediately without sending data to Umami. This is useful for 196 | development and testing environments. 197 | """ 198 | global tracking_enabled 199 | tracking_enabled = False 200 | 201 | 202 | async def new_event_async( 203 | event_name: str, 204 | hostname: Optional[str] = None, 205 | url: str = '/', 206 | website_id: Optional[str] = None, 207 | title: Optional[str] = None, 208 | custom_data: Optional[Dict[str, Any]] = None, 209 | referrer: str = '', 210 | language: str = 'en-US', 211 | screen: str = '1920x1080', 212 | ip_address: Optional[str] = None, 213 | ) -> str: 214 | """ 215 | Creates a new custom event in Umami for the given website_id and hostname (both use the default 216 | if you have set them with the other functions such as set_hostname()). These events will both 217 | appear in the traffic related to the specified url and in the events section at the bottom 218 | of your Umami website page. Login is not required for this method. 219 | 220 | Args: 221 | event_name: The name of your custom event (e.g. Purchase-Course) 222 | hostname: OPTIONAL: The value of your hostname simulating the client (e.g. test_domain.com), overrides set_hostname() value. 223 | url: The simulated URL for the custom event (e.g. if it's account creation, maybe /account/new) 224 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 225 | title: The title of the custom event (not sure how this is different from the name), defaults to event_name if empty. 226 | custom_data: Any additional data to send along with the event. Not visible in the UI but is in the API. 227 | referrer: The referrer of the client if there is any (what location lead them to this event) 228 | language: The language of the event / client. 229 | screen: The screen resolution of the client. 230 | ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. 231 | 232 | Returns: The data returned from the Umami API. 233 | """ 234 | validate_state(url=True, user=False) 235 | website_id = website_id or default_website_id 236 | hostname = hostname or default_hostname 237 | title = title or event_name 238 | custom_data = custom_data or {} 239 | 240 | validate_event_data(event_name, hostname, website_id) 241 | 242 | # Early return if tracking is disabled 243 | if not tracking_enabled: 244 | return '' 245 | 246 | api_url = f'{url_base}{urls.events}' 247 | headers = { 248 | 'User-Agent': event_user_agent, 249 | 'Authorization': f'Bearer {auth_token}', 250 | } 251 | 252 | payload = { 253 | 'hostname': hostname, 254 | 'language': language, 255 | 'referrer': referrer, 256 | 'screen': screen, 257 | 'title': title, 258 | 'url': url, 259 | 'website': website_id, 260 | 'name': event_name, 261 | 'data': custom_data, 262 | } 263 | 264 | if ip_address and ip_address.strip(): 265 | payload['ip'] = ip_address 266 | 267 | event_data = {'payload': payload, 'type': 'event'} 268 | 269 | async with httpx.AsyncClient() as client: 270 | resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) 271 | resp.raise_for_status() 272 | 273 | data_str = base64.b64decode(resp.text) 274 | return json.loads(data_str) 275 | 276 | 277 | def new_event( 278 | event_name: str, 279 | hostname: Optional[str] = None, 280 | url: str = '/event-api-endpoint', 281 | website_id: Optional[str] = None, 282 | title: Optional[str] = None, 283 | custom_data: Optional[Dict[str, Any]] = None, 284 | referrer: str = '', 285 | language: str = 'en-US', 286 | screen: str = '1920x1080', 287 | ip_address: Optional[str] = None, 288 | ): 289 | """ 290 | Creates a new custom event in Umami for the given website_id and hostname (both use the default 291 | if you have set them with the other functions such as set_hostname()). These events will both 292 | appear in the traffic related to the specified url and in the events section at the bottom 293 | of your Umami website page. Login is not required for this method. 294 | 295 | Args: 296 | event_name: The name of your custom event (e.g. Purchase-Course) 297 | hostname: OPTIONAL: The value of your hostname simulating the client (e.g. test_domain.com), overrides set_hostname() value. 298 | url: The simulated URL for the custom event (e.g. if it's account creation, maybe /account/new) 299 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 300 | title: The title of the custom event (not sure how this is different from the name), defaults to event_name if empty. 301 | custom_data: Any additional data to send along with the event. Not visible in the UI but is in the API. 302 | referrer: The referrer of the client if there is any (what location lead them to this event) 303 | language: The language of the event / client. 304 | screen: The screen resolution of the client. 305 | ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. 306 | """ 307 | validate_state(url=True, user=False) 308 | website_id = website_id or default_website_id 309 | hostname = hostname or default_hostname 310 | title = title or event_name 311 | custom_data = custom_data or {} 312 | 313 | validate_event_data(event_name, hostname, website_id) 314 | 315 | # Early return if tracking is disabled 316 | if not tracking_enabled: 317 | return 318 | 319 | api_url = f'{url_base}{urls.events}' 320 | headers = { 321 | 'User-Agent': event_user_agent, 322 | 'Authorization': f'Bearer {auth_token}', 323 | } 324 | 325 | payload = { 326 | 'hostname': hostname, 327 | 'language': language, 328 | 'referrer': referrer, 329 | 'screen': screen, 330 | 'title': title, 331 | 'url': url, 332 | 'website': website_id, 333 | 'name': event_name, 334 | 'data': custom_data, 335 | } 336 | 337 | if ip_address and ip_address.strip(): 338 | payload['ip'] = ip_address 339 | 340 | event_data = {'payload': payload, 'type': 'event'} 341 | 342 | resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) 343 | resp.raise_for_status() 344 | 345 | 346 | async def new_page_view_async( 347 | page_title: str, 348 | url: str, 349 | hostname: Optional[str] = None, 350 | website_id: Optional[str] = None, 351 | referrer: str = '', 352 | language: str = 'en-US', 353 | screen: str = '1920x1080', 354 | ua: str = event_user_agent, 355 | ip_address: Optional[str] = None, 356 | ): 357 | """ 358 | Creates a new page view event in Umami for the given website_id and hostname (both use the default 359 | if you have set them with the other functions such as set_hostname()). This is equivalent to what 360 | happens when a visit views a page and the JS library records it. 361 | 362 | Args: 363 | page_title: The title of the custom event (not sure how this is different from the name), defaults to event_name if empty. 364 | url: The simulated URL for the custom event (e.g. if it's account creation, maybe /account/new) 365 | hostname: OPTIONAL: The value of your hostname simulating the client (e.g. test_domain.com), overrides set_hostname() value. 366 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 367 | referrer: OPTIONAL: The referrer of the client if there is any (what location lead them to this event) 368 | language: OPTIONAL: The language of the event / client. 369 | screen: OPTIONAL: The screen resolution of the client. 370 | ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. 371 | ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. 372 | """ 373 | validate_state(url=True, user=False) 374 | website_id = website_id or default_website_id 375 | hostname = hostname or default_hostname 376 | 377 | validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) 378 | 379 | # Early return if tracking is disabled 380 | if not tracking_enabled: 381 | return 382 | 383 | api_url = f'{url_base}{urls.events}' 384 | headers = { 385 | 'User-Agent': ua, 386 | 'Authorization': f'Bearer {auth_token}', 387 | } 388 | 389 | payload = { 390 | 'hostname': hostname, 391 | 'language': language, 392 | 'referrer': referrer, 393 | 'screen': screen, 394 | 'title': page_title, 395 | 'url': url, 396 | 'website': website_id, 397 | } 398 | 399 | if ip_address and ip_address.strip(): 400 | payload['ip'] = ip_address 401 | 402 | event_data = {'payload': payload, 'type': 'event'} 403 | 404 | async with httpx.AsyncClient() as client: 405 | resp = await client.post(api_url, json=event_data, headers=headers, follow_redirects=True) 406 | resp.raise_for_status() 407 | 408 | 409 | def new_page_view( 410 | page_title: str, 411 | url: str, 412 | hostname: Optional[str] = None, 413 | website_id: Optional[str] = None, 414 | referrer: str = '', 415 | language: str = 'en-US', 416 | screen: str = '1920x1080', 417 | ua: str = event_user_agent, 418 | ip_address: Optional[str] = None, 419 | ): 420 | """ 421 | Creates a new page view event in Umami for the given website_id and hostname (both use the default 422 | if you have set them with the other functions such as set_hostname()). This is equivalent to what 423 | happens when a visit views a page and the JS library records it. 424 | 425 | Args: 426 | page_title: The title of the custom event (not sure how this is different from the name), defaults to event_name if empty. 427 | url: The simulated URL for the custom event (e.g. if it's account creation, maybe /account/new) 428 | hostname: OPTIONAL: The value of your hostname simulating the client (e.g. test_domain.com), overrides set_hostname() value. 429 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 430 | referrer: OPTIONAL: The referrer of the client if there is any (what location lead them to this event) 431 | language: OPTIONAL: The language of the event / client. 432 | screen: OPTIONAL: The screen resolution of the client. 433 | ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. 434 | ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. 435 | """ 436 | validate_state(url=True, user=False) 437 | website_id = website_id or default_website_id 438 | hostname = hostname or default_hostname 439 | 440 | validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) 441 | 442 | # Early return if tracking is disabled 443 | if not tracking_enabled: 444 | return 445 | 446 | api_url = f'{url_base}{urls.events}' 447 | headers = { 448 | 'User-Agent': ua, 449 | 'Authorization': f'Bearer {auth_token}', 450 | } 451 | 452 | payload = { 453 | 'hostname': hostname, 454 | 'language': language, 455 | 'referrer': referrer, 456 | 'screen': screen, 457 | 'title': page_title, 458 | 'url': url, 459 | 'website': website_id, 460 | } 461 | 462 | if ip_address and ip_address.strip(): 463 | payload['ip'] = ip_address 464 | 465 | event_data = {'payload': payload, 'type': 'event'} 466 | 467 | resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) 468 | resp.raise_for_status() 469 | 470 | 471 | def validate_event_data(event_name: str, hostname: Optional[str], website_id: Optional[str]): 472 | """ 473 | Internal use only. 474 | """ 475 | if not hostname: 476 | raise Exception('The hostname must be set, either as a parameter here or via set_hostname().') 477 | if not website_id: 478 | raise Exception('The website_id must be set, either as a parameter here or via set_website_id().') 479 | if not event_name and not event_name.strip(): 480 | raise Exception('The event_name is required.') 481 | 482 | 483 | async def verify_token_async(check_server: bool = True) -> bool: 484 | """ 485 | Verifies that the token set when you called login() is still valid. Umami says this token will expire, 486 | but I'm not sure if that's minutes, hours, or years. 487 | 488 | Args: 489 | check_server: If true, we will contact the server and verify that the token is valid. 490 | If false, this only checks that an auth token has been stored from a previous successful login. 491 | 492 | Returns: True if the token is still valid, False otherwise. 493 | """ 494 | # noinspection PyBroadException 495 | try: 496 | global auth_token 497 | validate_state(url=True, user=True) 498 | 499 | if not check_server: 500 | return True 501 | 502 | url = f'{url_base}{urls.verify}' 503 | headers = { 504 | 'User-Agent': event_user_agent, 505 | 'Authorization': f'Bearer {auth_token}', 506 | } 507 | async with httpx.AsyncClient() as client: 508 | resp = await client.post(url, headers=headers, follow_redirects=True) 509 | resp.raise_for_status() 510 | 511 | return 'username' in resp.json() 512 | except Exception: 513 | return False 514 | 515 | 516 | def verify_token(check_server: bool = True) -> bool: 517 | """ 518 | Verifies that the token set when you called login() is still valid. Umami says this token will expire, 519 | but I'm not sure if that's minutes, hours, or years. 520 | 521 | Args: 522 | check_server: If true, we will contact the server and verify that the token is valid. 523 | If false, this only checks that an auth token has been stored from a previous successful login. 524 | 525 | Returns: True if the token is still valid, False otherwise. 526 | """ 527 | # noinspection PyBroadException 528 | try: 529 | global auth_token 530 | validate_state(url=True, user=True) 531 | 532 | if not check_server: 533 | return True 534 | 535 | url = f'{url_base}{urls.verify}' 536 | headers = { 537 | 'User-Agent': event_user_agent, 538 | 'Authorization': f'Bearer {auth_token}', 539 | } 540 | resp = httpx.post(url, headers=headers, follow_redirects=True) 541 | resp.raise_for_status() 542 | 543 | return 'username' in resp.json() 544 | except Exception: 545 | return False 546 | 547 | 548 | async def heartbeat_async() -> bool: 549 | """ 550 | Verifies that the server is reachable via the internet and is healthy. 551 | 552 | Returns: True if the server is healthy and accessible. 553 | """ 554 | # noinspection PyBroadException 555 | try: 556 | global auth_token 557 | validate_state(url=True, user=False) 558 | 559 | url = f'{url_base}{urls.heartbeat}' 560 | headers = { 561 | 'User-Agent': user_agent, 562 | } 563 | async with httpx.AsyncClient() as client: 564 | resp = await client.post(url, headers=headers, follow_redirects=True) 565 | resp.raise_for_status() 566 | 567 | return True 568 | except Exception: 569 | return False 570 | 571 | 572 | def heartbeat() -> bool: 573 | """ 574 | Verifies that the server is reachable via the internet and is healthy. 575 | 576 | Returns: True if the server is healthy and accessible. 577 | """ 578 | # noinspection PyBroadException 579 | try: 580 | global auth_token 581 | validate_state(url=True, user=False) 582 | 583 | url = f'{url_base}{urls.heartbeat}' 584 | headers = { 585 | 'User-Agent': user_agent, 586 | } 587 | resp = httpx.post(url, headers=headers, follow_redirects=True) 588 | resp.raise_for_status() 589 | 590 | return True 591 | except Exception: 592 | return False 593 | 594 | 595 | def validate_login(email: str, password: str) -> None: 596 | """ 597 | Internal helper function, not need to use this. 598 | """ 599 | if not email: 600 | raise ValidationError('Email cannot be empty') 601 | if not password: 602 | raise ValidationError('Password cannot be empty') 603 | 604 | 605 | async def active_users_async(website_id: Optional[str] = None) -> int: 606 | """ 607 | Retrieves the active users for a specific website. 608 | 609 | Args: 610 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 611 | 612 | 613 | Returns: The number of active users. 614 | """ 615 | validate_state(url=True, user=True) 616 | 617 | website_id = website_id or default_website_id 618 | 619 | url = f'{url_base}{urls.websites}/{website_id}/active' 620 | headers = { 621 | 'User-Agent': user_agent, 622 | 'Authorization': f'Bearer {auth_token}', 623 | } 624 | 625 | async with httpx.AsyncClient() as client: 626 | resp = await client.get(url, headers=headers, follow_redirects=True) 627 | resp.raise_for_status() 628 | 629 | return int(resp.json().get('x', 0)) 630 | 631 | 632 | def active_users(website_id: Optional[str] = None) -> int: 633 | """ 634 | Retrieves the active users for a specific website. 635 | 636 | Args: 637 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 638 | 639 | 640 | Returns: The number of active users. 641 | """ 642 | validate_state(url=True, user=True) 643 | 644 | website_id = website_id or default_website_id 645 | 646 | url = f'{url_base}{urls.websites}/{website_id}/active' 647 | headers = { 648 | 'User-Agent': user_agent, 649 | 'Authorization': f'Bearer {auth_token}', 650 | } 651 | 652 | resp = httpx.get(url, headers=headers, follow_redirects=True) 653 | resp.raise_for_status() 654 | 655 | return int(resp.json().get('x', 0)) 656 | 657 | 658 | async def website_stats_async( 659 | start_at: datetime, 660 | end_at: datetime, 661 | website_id: Optional[str] = None, 662 | url: Optional[str] = None, 663 | referrer: Optional[str] = None, 664 | title: Optional[str] = None, 665 | query: Optional[str] = None, 666 | event: Optional[str] = None, 667 | host: Optional[str] = None, 668 | os: Optional[str] = None, 669 | browser: Optional[str] = None, 670 | device: Optional[str] = None, 671 | country: Optional[str] = None, 672 | region: Optional[str] = None, 673 | city: Optional[str] = None, 674 | ) -> models.WebsiteStats: 675 | """ 676 | Retrieves the statistics for a specific website. 677 | 678 | Args: 679 | start_at: Starting date as a datetime object. 680 | end_at: End date as a datetime object. 681 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 682 | url: OPTIONAL: Name of URL. 683 | referrer: OPTIONAL: Name of referrer. 684 | title: OPTIONAL: Name of page title. 685 | query: OPTIONAL: Name of query. 686 | event: OPTIONAL: Name of event. 687 | host: OPTIONAL: Name of hostname. 688 | os: OPTIONAL: Name of operating system. 689 | browser: OPTIONAL: Name of browser. 690 | device: OPTIONAL: Name of device (ex. Mobile) 691 | country: OPTIONAL: Name of country. 692 | region: OPTIONAL: Name of region/state/province. 693 | city: OPTIONAL: Name of city. 694 | 695 | Returns: A WebsiteStatsResponse model containing the website statistics data. 696 | """ 697 | validate_state(url=True, user=True) 698 | 699 | website_id = website_id or default_website_id 700 | 701 | api_url = f'{url_base}{urls.websites}/{website_id}/stats' 702 | 703 | headers = { 704 | 'User-Agent': user_agent, 705 | 'Authorization': f'Bearer {auth_token}', 706 | } 707 | params = { 708 | 'start_at': int(start_at.timestamp() * 1000), 709 | 'end_at': int(end_at.timestamp() * 1000), 710 | } 711 | optional_params: dict[str, Any] = { 712 | 'url': url, 713 | 'referrer': referrer, 714 | 'title': title, 715 | 'query': query, 716 | 'event': event, 717 | 'host': host, 718 | 'os': os, 719 | 'browser': browser, 720 | 'device': device, 721 | 'country': country, 722 | 'region': region, 723 | 'city': city, 724 | } 725 | params.update({k: v for k, v in optional_params.items() if v is not None}) 726 | 727 | async with httpx.AsyncClient() as client: 728 | resp = await client.get(api_url, headers=headers, params=params, follow_redirects=True) 729 | resp.raise_for_status() 730 | 731 | return models.WebsiteStats(**resp.json()) 732 | 733 | 734 | def website_stats( 735 | start_at: datetime, 736 | end_at: datetime, 737 | website_id: Optional[str] = None, 738 | url: Optional[str] = None, 739 | referrer: Optional[str] = None, 740 | title: Optional[str] = None, 741 | query: Optional[str] = None, 742 | event: Optional[str] = None, 743 | host: Optional[str] = None, 744 | os: Optional[str] = None, 745 | browser: Optional[str] = None, 746 | device: Optional[str] = None, 747 | country: Optional[str] = None, 748 | region: Optional[str] = None, 749 | city: Optional[str] = None, 750 | ) -> models.WebsiteStats: 751 | """ 752 | Retrieves the statistics for a specific website. 753 | 754 | Args: 755 | start_at: Starting date as a datetime object. 756 | end_at: End date as a datetime object. 757 | url: OPTIONAL: Name of URL. 758 | website_id: OPTIONAL: The value of your website_id in Umami. (overrides set_website_id() value). 759 | referrer: OPTIONAL: Name of referrer. 760 | title: (OPTIONAL: Name of page title. 761 | query: OPTIONAL: Name of query. 762 | event: OPTIONAL: Name of event. 763 | host: OPTIONAL: Name of hostname. 764 | os: OPTIONAL: Name of operating system. 765 | browser: OPTIONAL: Name of browser. 766 | device: OPTIONAL: Name of device (ex. Mobile) 767 | country: OPTIONAL: Name of country. 768 | region: OPTIONAL: Name of region/state/province. 769 | city: OPTIONAL: Name of city. 770 | 771 | Returns: A WebsiteStatsResponse model containing the website statistics data. 772 | """ 773 | validate_state(url=True, user=True) 774 | 775 | website_id = website_id or default_website_id 776 | 777 | api_url = f'{url_base}{urls.websites}/{website_id}/stats' 778 | 779 | headers = { 780 | 'User-Agent': user_agent, 781 | 'Authorization': f'Bearer {auth_token}', 782 | } 783 | params = { 784 | 'startAt': int(start_at.timestamp() * 1000), 785 | 'endAt': int(end_at.timestamp() * 1000), 786 | } 787 | optional_params: dict[str, Any] = { 788 | 'url': url, 789 | 'referrer': referrer, 790 | 'title': title, 791 | 'query': query, 792 | 'event': event, 793 | 'host': host, 794 | 'os': os, 795 | 'browser': browser, 796 | 'device': device, 797 | 'country': country, 798 | 'region': region, 799 | 'city': city, 800 | } 801 | params.update({k: v for k, v in optional_params.items() if v is not None}) 802 | 803 | resp = httpx.get(api_url, headers=headers, params=params, follow_redirects=True) 804 | resp.raise_for_status() 805 | 806 | return models.WebsiteStats(**resp.json()) 807 | 808 | 809 | def validate_state(url: bool = False, user: bool = False): 810 | """ 811 | Internal helper function, not need to use this. 812 | """ 813 | if url and not url_base: 814 | raise OperationNotAllowedError('URL Base must be set to proceed.') 815 | 816 | if user and not auth_token: 817 | raise OperationNotAllowedError('You must login before proceeding.') 818 | -------------------------------------------------------------------------------- /umami/umami/models/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pydantic 4 | 5 | 6 | class User(pydantic.BaseModel): 7 | id: str 8 | username: str 9 | role: str 10 | createdAt: str 11 | isAdmin: bool 12 | 13 | 14 | class LoginResponse(pydantic.BaseModel): 15 | token: str 16 | user: User 17 | 18 | 19 | class WebsiteUser(pydantic.BaseModel): 20 | username: str 21 | id: str 22 | 23 | 24 | class Website(pydantic.BaseModel): 25 | id: str 26 | name: typing.Optional[str] = None 27 | domain: str 28 | shareId: typing.Any 29 | resetAt: typing.Any 30 | userId: typing.Optional[str] = None 31 | createdAt: str 32 | updatedAt: str 33 | deletedAt: typing.Any 34 | teamId: typing.Optional[str] = None 35 | user: WebsiteUser 36 | 37 | 38 | class Metric(pydantic.BaseModel): 39 | value: int 40 | prev: int 41 | 42 | 43 | class WebsiteStats(pydantic.BaseModel): 44 | pageviews: Metric 45 | visitors: Metric 46 | visits: Metric 47 | bounces: Metric 48 | totaltime: Metric 49 | 50 | 51 | class WebsitesResponse(pydantic.BaseModel): 52 | websites: list[Website] = pydantic.Field(alias='data') 53 | count: int 54 | page: int 55 | pageSize: int 56 | orderBy: typing.Optional[str] = None 57 | -------------------------------------------------------------------------------- /umami/umami/urls.py: -------------------------------------------------------------------------------- 1 | login = '/api/auth/login' 2 | websites = '/api/websites' 3 | events = '/api/send' 4 | verify = '/api/auth/verify' 5 | heartbeat = '/api/heartbeat' 6 | --------------------------------------------------------------------------------