├── .bumpversion.cfg ├── .coveragerc ├── .darglint ├── .editorconfig ├── .flake8 ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── dev.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── .pydocstyle ├── CONTRIBUTING.md ├── HISTORY.md ├── LICENSE ├── README.md ├── plausible_proxy ├── __init__.py ├── apps.py ├── services.py ├── templatetags │ ├── __init__.py │ └── plausible.py ├── urls.py └── views.py ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── test_services.py ├── test_templatetags.py ├── test_views.py └── urlconf.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.5.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [bumpversion:file:plausible_proxy/__init__.py] 11 | search = __version__ = "{current_version}" 12 | replace = __version__ = "{new_version}" 13 | 14 | [bumpversion:file:HISTORY.md] 15 | search = UNRELEASED 16 | replace = {new_version} ({now:%Y-%m-%d}) 17 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | # uncomment the following to omit files during running 3 | #omit = 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | def __repr__ 8 | if self.debug: 9 | if settings.DEBUG 10 | raise AssertionError 11 | raise NotImplementedError 12 | if 0: 13 | if __name__ == .__main__.: 14 | def main 15 | -------------------------------------------------------------------------------- /.darglint: -------------------------------------------------------------------------------- 1 | [darglint] 2 | # Variants: short, long, full, where "long" is the most permissive. 3 | # Ref: https://github.com/terrencepreilly/darglint#strictness-configuration 4 | strictness = long 5 | 6 | # Ref: https://google.github.io/styleguide/pyguide.html#383-functions-and-methods 7 | docstring_style = google 8 | 9 | # Ignore exception-based errors (Missing exception, Excess exception). 10 | # 11 | # Quite often, darglint can't properly detect when and which exceptions are raised, 12 | # so we mute these reports. 13 | ignore = DAR401,DAR402 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | charset = utf-8 10 | end_of_line = lf 11 | 12 | [*.bat] 13 | indent_style = tab 14 | end_of_line = crlf 15 | 16 | [*.{json,yaml,yml}] 17 | indent_size = 2 18 | 19 | [LICENSE] 20 | insert_final_newline = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # required by black, https://github.com/psf/black/blob/master/.flake8 3 | max-line-length = 88 4 | max-complexity = 18 5 | ignore = E203, E266, E501, W503, F403, F401 6 | select = B,C,E,F,W,T4,B9 7 | docstring-convention = google 8 | per-file-ignores = 9 | __init__.py:F401 10 | exclude = 11 | .git, 12 | __pycache__, 13 | setup.py, 14 | build, 15 | dist, 16 | releases, 17 | .venv, 18 | .tox, 19 | .mypy_cache, 20 | .pytest_cache, 21 | .vscode, 22 | .github, 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Django Plausible Proxy version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: dev workflow 2 | 3 | # Controls when the action will run. 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | test: 15 | strategy: 16 | matrix: 17 | python-versions: ["3.8", "3.9", "3.10", "3.11"] 18 | os: [ubuntu-latest] 19 | runs-on: ${{ matrix.os }} 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-versions }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | set -ex 32 | curl -sSL https://install.python-poetry.org | python3 - 33 | $HOME/.local/bin/poetry install 34 | 35 | - name: Install tox 36 | run: | 37 | $HOME/.local/bin/poetry add tox tox-gh-actions 38 | 39 | - name: Test with tox 40 | run: 41 | $HOME/.local/bin/poetry run tox 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | /dist 6 | 7 | # Unit test / coverage reports 8 | htmlcov/ 9 | .tox/ 10 | .coverage 11 | .coverage.* 12 | .cache 13 | coverage.xml 14 | *.cover 15 | .pytest_cache/ 16 | 17 | # mypy 18 | .mypy_cache/ 19 | 20 | # IDE settings 21 | .vscode/ 22 | 23 | # For libraries, we don't want to commit the lockfile 24 | poetry.lock 25 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | profile = black 3 | multi_line_output=3 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.9 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v4.4.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: check-merge-conflict 10 | - id: check-case-conflict 11 | - id: debug-statements 12 | 13 | - repo: https://github.com/pycqa/pydocstyle 14 | rev: 6.3.0 15 | hooks: 16 | - id: pydocstyle 17 | 18 | - repo: https://github.com/terrencepreilly/darglint 19 | rev: v1.8.1 20 | hooks: 21 | - id: darglint 22 | 23 | - repo: https://github.com/myint/autoflake 24 | rev: v2.1.1 25 | hooks: 26 | - id: autoflake 27 | args: 28 | - --in-place 29 | - --remove-unused-variables 30 | - --remove-all-unused-imports 31 | - --expand-star-imports 32 | 33 | - repo: https://github.com/psf/black 34 | rev: 23.3.0 35 | hooks: 36 | - id: black 37 | 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.3.0 40 | hooks: 41 | - id: mypy 42 | additional_dependencies: [types-requests] 43 | 44 | - repo: https://github.com/PyCQA/isort 45 | rev: 5.12.0 46 | hooks: 47 | - id: isort 48 | 49 | - repo: https://github.com/pycqa/flake8 50 | rev: 6.0.0 51 | hooks: 52 | - id: flake8 53 | -------------------------------------------------------------------------------- /.pydocstyle: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | # Based on Google convention: 3 | # Ref: http://www.pydocstyle.org/en/stable/error_codes.html#default-conventions 4 | # Except for the following: 5 | # - Ignore D1 (missing docstrings) 6 | ignore = D1, D203, D204, D213, D215, D400, D401, D404, D406, D407, D408, D409, D413 7 | 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit 4 | helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/imankulov/django-plausible-proxy/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 23 | wanted" is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "enhancement" 28 | and "help wanted" is open to whoever wants to implement it. 29 | 30 | ### Write Documentation 31 | 32 | Django Plausible Proxy could always use more documentation, whether as part of the 33 | official Django Plausible Proxy docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | ### Submit Feedback 37 | 38 | The best way to send feedback is to file an issue at https://github.com/imankulov/django-plausible-proxy/issues. 39 | 40 | If you are proposing a feature: 41 | 42 | * Explain in detail how it would work. 43 | * Keep the scope as narrow as possible, to make it easier to implement. 44 | * Remember that this is a volunteer-driven project, and that contributions 45 | are welcome :) 46 | 47 | ## Get Started! 48 | 49 | Ready to contribute? Here's how to set up `django-plausible-proxy` for local development. 50 | 51 | 1. Fork the `django-plausible-proxy` repo on GitHub. 52 | 2. Clone your fork locally 53 | 54 | ``` 55 | $ git clone git@github.com:your_name_here/django-plausible-proxy.git 56 | ``` 57 | 58 | 3. Ensure [poetry](https://python-poetry.org/docs/) is installed. 59 | 4. Install dependencies and start your virtualenv: 60 | 61 | ``` 62 | $ poetry install -E test -E doc -E dev 63 | ``` 64 | 65 | 5. Create a branch for local development: 66 | 67 | ``` 68 | $ git checkout -b name-of-your-bugfix-or-feature 69 | ``` 70 | 71 | Now you can make your changes locally. 72 | 73 | 6. When you're done making changes, check that your changes pass the 74 | tests, including testing other Python versions, with tox: 75 | 76 | ``` 77 | $ tox 78 | ``` 79 | 80 | 7. Commit your changes and push your branch to GitHub: 81 | 82 | ``` 83 | $ git add . 84 | $ git commit -m "Your detailed description of your changes." 85 | $ git push origin name-of-your-bugfix-or-feature 86 | ``` 87 | 88 | 8. Submit a pull request through the GitHub website. 89 | 90 | ## Pull Request Guidelines 91 | 92 | Before you submit a pull request, check that it meets these guidelines: 93 | 94 | 1. The pull request should include tests. 95 | 2. If the pull request adds functionality, the docs should be updated. Put 96 | your new functionality into a function with a docstring, and add the 97 | feature to the list in README.md. 98 | 3. The pull request should work for all supported versions. Check 99 | https://github.com/imankulov/django-plausible-proxy/actions 100 | and make sure that the tests pass. 101 | 102 | ## Tips 103 | 104 | ``` 105 | $ pytest tests 106 | ``` 107 | 108 | To run a subset of tests. 109 | 110 | 111 | ## Deploying 112 | 113 | A reminder for the maintainers on how to deploy. 114 | Make sure all your changes are committed (including an entry in HISTORY.md). 115 | Then run: 116 | 117 | ``` 118 | $ poetry patch # possible: major / minor / patch 119 | $ git push 120 | $ git push --tags 121 | ``` 122 | 123 | Travis will then deploy to PyPI if tests pass. 124 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## 0.5.1 (2023-07-19) 4 | 5 | - Made it possible to proxy script names with combined extensions 6 | 7 | ## 0.5.0 (2023-05-25) 8 | 9 | - Drop support for Python 3.7. 10 | - Make it possible to call send_custom_event() with explicit remote_addr. 11 | - Make it possible to render the {% plausible %} templatetag without the request object, when PLAUSIBLE_DOMAIN is set 12 | in settings. Thanks @hendi for the report. Ref #7. 13 | 14 | ## 0.4.0 (2023-03-30) 15 | 16 | - Added support for Python 3.11. 17 | - Set timeout for upstream requests and added PLAUSIBLE_REQUEST_TIMEOUT settings option to override it. Thanks @yoshson for the contribution. PR #5. 18 | 19 | ## 0.3.0 (2022-06-06) 20 | 21 | - Added PLAUSIBLE_SCRIPT_PREFIX to make it possible override default location of the proxy script (`js/script.js` -> `${PLAUSIBLE_SCRIPT_PREFIX}/script.js`). Thanks @aareman for the suggestion. Ref #2. 22 | 23 | ## 0.2.0 (2022-06-06) 24 | 25 | - Added PLAUSIBLE_BASE_URL settings option to make it possible to use the project with self-hosted Plausible installations. The default value is https://plausible.io (use the cloud version.) Thanks @aareman for the contribution. PR #1. 26 | - Made PLAUSIBLE_DOMAIN settings optional. If the value is not set, the domain name is taken from the request. 27 | - Added more tests. 28 | 29 | ## 0.1.2 (2022-04-25) 30 | 31 | - Fixed app config 32 | 33 | ## 0.1.1 (2022-04-25) 34 | 35 | - Fixed project metadata 36 | 37 | ## 0.1.0 (2022-04-25) 38 | 39 | - First release on PyPI. 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022, Roman Imankulov 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Dev workflow](https://github.com/imankulov/django-plausible-proxy/workflows/dev%20workflow/badge.svg) 2 | ![PyPI](https://img.shields.io/pypi/v/django-plausible-proxy.svg) 3 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-plausible-proxy.svg) 4 | ![PyPI - Status](https://img.shields.io/pypi/status/django-plausible-proxy.svg) 5 | ![PyPI - License](https://img.shields.io/pypi/l/django-plausible-proxy.svg) 6 | 7 | 8 | # Django Plausible Proxy 9 | 10 | Django application to proxy requests and send server-side events to Plausible Analytics. Plays well with self-hosted and the managed cloud service. 11 | 12 | ## Proxying 13 | 14 | Proxying allows a project owner concerned about missing data seeing a more complete picture. See [Adblockers and using a proxy for analytics](https://plausible.io/docs/proxy/introduction) for the detailed outline of the problem and solution. 15 | 16 | When installed and configured in `settings.py` and `urls.py`, the app proxies the HTTP requests as such: 17 | 18 | ``` 19 | https:///js/script.js -> https://plausible.io/js/script.js 20 | https:///api/event -> https://plausible.io/api/event 21 | ``` 22 | 23 | ## Server-side events 24 | 25 | Track on the server side events that can't be tracked otherwise, such as API requests. 26 | 27 | ```python 28 | from plausible_proxy import send_custom_event 29 | ... 30 | send_custom_event(request, name="Register", props={"plan": "Premium"}) 31 | ``` 32 | 33 | ## Installation 34 | 35 | Install the package from PyPI. 36 | 37 | ```shell 38 | pip install django-plausible-proxy 39 | ``` 40 | 41 | Configure Django setting in the `settings.py`. 42 | 43 | ```python 44 | 45 | # Register the app to enable {% plausble %} templatetag. 46 | INSTALLED_APPS = [ 47 | # ... 48 | "plausible_proxy" 49 | # ... 50 | ] 51 | 52 | # Optionally, define a default value for Plausible domain to provide a default value 53 | # for the Plausible domain and the `send_custom_event()` function. 54 | PLAUSIBLE_DOMAIN = "yourdomain.com" 55 | 56 | # Optionally, define the plausible endpoint that you would like to post to. 57 | # This is useful if you are self-hosting plausible. 58 | PLAUSIBLE_BASE_URL = "https://plausible.io" 59 | 60 | # Optionally, define the value for the script prefix. The default value is "js". When 61 | # you include the script to the page with the {% plausible %} templatetag, it becomes 62 | # available as "". E.g., 63 | # "" 64 | # 65 | # Overriding PLAUSIBLE_SCRIPT_PREFIX is helpful to avoid clashes with another script 66 | # of your site that may become available under the same name. 67 | 68 | PLAUSIBLE_SCRIPT_PREFIX = "plsbl/js" 69 | 70 | # Optionally, provide a timeout for the connection to your plausible endpoint in 71 | # seconds. Defaults to 1 second. Adjust to lower values in case you can't trust your 72 | # infrastructure to consistently deliver low load times and you don't care as much 73 | # about consistent analytics. 74 | PLAUSIBLE_REQUEST_TIMEOUT = 1 75 | ``` 76 | 77 | Update `urls.py`. 78 | 79 | 80 | ```python 81 | from django.urls import include, path 82 | 83 | urlpatterns = [ 84 | # ... 85 | path("", include("plausible_proxy.urls")), 86 | # ... 87 | ] 88 | ``` 89 | 90 | Update your base HTML template to include the plausible templatetag. 91 | 92 | ```html 93 | {% load plausible %} 94 | 95 | 96 | ... 97 | {% plausible script='script.js' %} 98 | 99 | ``` 100 | 101 | ## API reference 102 | 103 | 104 | ### **`{% plausible %}`** 105 | 106 | A templatetag to include the Plausible analytics script to the page. 107 | 108 | Arguments: 109 | 110 | - `domain` (default to `settings.PLAUSIBLE_DOMAIN`): defines the `data-domain` parameter, the is the domain for the Plausible analytics. 111 | - `script` (default to `script.js`): defines the Plausible script to use. See [Script extensions for enhanced measurement](https://plausible.io/docs/script-extensions) for the list of alternative script names and what they can track for you. 112 | 113 | Usage example: 114 | 115 | ```html 116 | {% load plausible %} 117 | 118 | 119 | ... 120 | {% plausible domain='example.com' script='script.outbound-links.js' %} 121 | 122 | ``` 123 | 124 | ### `plausible_proxy.services.`**`send_custom_event()`** 125 | 126 | end a custom event to Plausible and return successful status. 127 | 128 | See [Plausible events API](https://plausible.io/docs/events-api) for more information 129 | 130 | Arguments: 131 | 132 | - `request` (HttpRequest): Original Django HTTP request. Will be used to create X-Forwarded-For and User-Agent headers. 133 | - `name` (string): Name of the event. Can specify `pageview` which is a special type of event in Plausible. All other names will be treated as custom events. 134 | - `domain` (optional string): Domain name of the site in Plausible. The value from settings.PLAUSIBLE_DOMAIN is used by default. 135 | - `url` (optional string): URL of the page where the event was triggered. If not provided, the function extracts the URL from the request. If the URL contains UTM parameters, they will be extracted and stored. If URL is not set, will be extracted from the request. 136 | - `referrer` (optional string): Referrer for this event. 137 | - `screen_width` (optional integer): Width of the screen. 138 | - `props` (optional dict): Custom properties for the event. See: [Using custom props](https://plausible.io/docs/custom-event-goals#using-custom-props). 139 | 140 | Returns: True if request was accepted successfully. 141 | 142 | Example: 143 | 144 | ```python 145 | def vote(request, candidate_id): 146 | candidate = get_object_or_404(Candidate, pk=candidate_id) 147 | send_custom_event(request, 'vote', props={"candidate": candidate.full_name}) 148 | ... 149 | ``` 150 | 151 | ## Contributors 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /plausible_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | from plausible_proxy.services import send_custom_event 2 | 3 | __version__ = "0.5.1" 4 | __all__ = [ 5 | "send_custom_event", 6 | ] 7 | -------------------------------------------------------------------------------- /plausible_proxy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoPlausibleConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "plausible_proxy" 7 | verbose_name = "Plausible Proxy" 8 | -------------------------------------------------------------------------------- /plausible_proxy/services.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict, Optional, Tuple 3 | 4 | import requests 5 | from django.conf import settings 6 | from django.core.cache import cache 7 | from django.http import HttpRequest 8 | 9 | # Ref: https://plausible.io/docs/script-extensions 10 | ALLOWED_SCRIPT_REGEX = re.compile(r"^script([a-z.-]*)\.js$") 11 | 12 | CACHE_TTL = 86400 13 | 14 | 15 | SCRIPT_HEADERS = { 16 | "cache-control": f"public, must-revalidate, max-age={CACHE_TTL}", 17 | "content-type": "application/javascript", 18 | } 19 | 20 | EVENT_HEADERS = { 21 | "content-type": "text/plain; charset=utf-8", 22 | "cache-control": "must-revalidate, max-age=0, private", 23 | } 24 | 25 | REQUEST_TIMEOUT = getattr(settings, "PLAUSIBLE_REQUEST_TIMEOUT", 1) 26 | 27 | ContentAndHeaders = Tuple[bytes, Dict[str, str]] 28 | 29 | 30 | def get_script(script_name: str) -> ContentAndHeaders: 31 | """Return the script by its name. 32 | 33 | Args: 34 | script_name: the name of the script to download. E.g., `script.js` 35 | 36 | Raises: 37 | requests.HTTPError if script can't be downloaded. 38 | ValueError if script_name is invalid. 39 | 40 | Returns: 41 | The contents of the script as bytes (not a string) and headers to be passed 42 | back to the response. 43 | """ 44 | if not ALLOWED_SCRIPT_REGEX.match(script_name): 45 | raise ValueError(f"Unknown script {script_name}") 46 | 47 | cache_key = f"plausible:script:{script_name}" 48 | sentinel = object() 49 | 50 | script_bytes = cache.get(cache_key, sentinel) 51 | if script_bytes is not sentinel: 52 | return script_bytes, SCRIPT_HEADERS 53 | 54 | resp = requests.get( 55 | f"{get_plausible_base_url()}/js/{script_name}", timeout=REQUEST_TIMEOUT 56 | ) 57 | resp.raise_for_status() 58 | script_bytes = resp.content 59 | cache.set(cache_key, script_bytes, CACHE_TTL) 60 | return script_bytes, SCRIPT_HEADERS 61 | 62 | 63 | def send_custom_event( 64 | request: HttpRequest, 65 | name: str, 66 | domain: Optional[str] = None, 67 | url: Optional[str] = None, 68 | referrer: Optional[str] = None, 69 | screen_width: Optional[int] = None, 70 | remote_addr: Optional[str] = None, 71 | props: Optional[Dict[str, Any]] = None, 72 | ) -> bool: 73 | """Send a custom event to Plausible and return successful status. 74 | 75 | Ref: https://plausible.io/docs/events-api 76 | 77 | Args: 78 | request: Original Django HTTP request. Will be used to create X-Forwarded-For 79 | and User-Agent headers. 80 | domain: Domain name of the site in Plausible. The value from 81 | settings.PLAUSIBLE_DOMAIN is used by default. 82 | name: Name of the event. Can specify `pageview` which is a special type of 83 | event in Plausible. All other names will be treated as custom events. 84 | url: URL of the page where the event was triggered. If the URL 85 | contains UTM parameters, they will be extracted and stored. If URL is not 86 | set, will be extracted from the request. 87 | referrer: Referrer for this event. 88 | screen_width: Width of the screen. 89 | remote_addr: Remote address of the user. If not set, will be extracted from 90 | the request. 91 | props: Custom properties for the event. See: 92 | https://plausible.io/docs/custom-event-goals#using-custom-props 93 | 94 | Returns: 95 | True if request was accepted successfully. 96 | """ 97 | if url is None: 98 | url = request.build_absolute_uri() 99 | if domain is None: 100 | domain = get_default_domain(request) 101 | 102 | event_data = { 103 | "name": name, 104 | "domain": domain, 105 | "url": url, 106 | "referrer": referrer, 107 | "screen_width": screen_width, 108 | "props": props, 109 | } 110 | event_data = {k: v for k, v in event_data.items() if v is not None} 111 | 112 | try: 113 | resp = requests.post( 114 | get_plausible_event_api_endpoint(), 115 | json=event_data, 116 | headers={ 117 | "content-type": "application/json", 118 | "x-forwarded-for": remote_addr or get_xff(request), 119 | "user-agent": get_user_agent(request), 120 | }, 121 | timeout=REQUEST_TIMEOUT, 122 | ) 123 | return resp.ok 124 | except requests.Timeout: 125 | return False 126 | 127 | 128 | def get_xff(request: HttpRequest) -> str: 129 | """Extract and update X-Forwarded-For from the request.""" 130 | remote_addr = request.META["REMOTE_ADDR"] 131 | xff = request.META.get("HTTP_X_FORWARDED_FOR") 132 | if xff: 133 | return f"{xff}, {remote_addr}" 134 | return remote_addr 135 | 136 | 137 | def get_user_agent(request: HttpRequest) -> str: 138 | """Extract User-Agent header from the request.""" 139 | return request.META.get("HTTP_USER_AGENT") or "" 140 | 141 | 142 | def get_default_domain(request: HttpRequest) -> str: 143 | """Return default Plausible domain for send_custom_event(). 144 | 145 | The value is taken from settings.PLAUSIBLE_DOMAIN 146 | """ 147 | return getattr(settings, "PLAUSIBLE_DOMAIN", request.get_host()) 148 | 149 | 150 | def get_plausible_base_url() -> str: 151 | """Return the Plausible base URL. 152 | 153 | The variable defines the destination to send events. Default to 154 | https://plausible.io, but you can set a custom value in PLAUSIBLE_BASE_URL if you 155 | want to send events to your local Plausible installation. 156 | """ 157 | return getattr(settings, "PLAUSIBLE_BASE_URL", "https://plausible.io") 158 | 159 | 160 | def get_plausible_event_api_endpoint() -> str: 161 | return f"{get_plausible_base_url()}/api/event" 162 | 163 | 164 | def get_script_prefix() -> str: 165 | """Return the script prefix.""" 166 | return getattr(settings, "PLAUSIBLE_SCRIPT_PREFIX", "js") 167 | -------------------------------------------------------------------------------- /plausible_proxy/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imankulov/django-plausible-proxy/fef53201a00c133051fff300516bef2277cfd9ac/plausible_proxy/templatetags/__init__.py -------------------------------------------------------------------------------- /plausible_proxy/templatetags/plausible.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.conf import settings 3 | from django.forms.utils import flatatt 4 | from django.urls import reverse 5 | from django.utils.safestring import mark_safe 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.simple_tag(takes_context=True) 11 | def plausible(context, domain=None, script="script.js"): 12 | """Return a script tag referencing the script.js. 13 | 14 | Args: 15 | context: Template context. 16 | domain: The value to include in the `` 25 | """ 26 | if domain is None: 27 | domain = getattr(settings, "PLAUSIBLE_DOMAIN", None) 28 | if domain is None: 29 | request = context.get("request") 30 | if request is None: 31 | raise ValueError( 32 | "PLAUSIBLE_DOMAIN is not defined and request is not set in context." 33 | ) 34 | domain = request.get_host() 35 | attrs = { 36 | "defer": True, 37 | "data-domain": domain, 38 | "src": reverse("plausible:script-proxy", args=(script,)), 39 | } 40 | return mark_safe(f"") 41 | -------------------------------------------------------------------------------- /plausible_proxy/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from plausible_proxy.services import get_script_prefix 4 | from plausible_proxy.views import event_proxy, script_proxy 5 | 6 | app_name = "plausible" 7 | 8 | urlpatterns = [ 9 | path(f"{get_script_prefix()}/", script_proxy, name="script-proxy"), 10 | path("api/event", event_proxy, name="event-proxy"), 11 | ] 12 | -------------------------------------------------------------------------------- /plausible_proxy/views.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from django.http import ( 3 | HttpRequest, 4 | HttpResponse, 5 | HttpResponseNotFound, 6 | HttpResponseServerError, 7 | ) 8 | from django.views.decorators.csrf import csrf_exempt 9 | 10 | from plausible_proxy.services import ( 11 | EVENT_HEADERS, 12 | REQUEST_TIMEOUT, 13 | get_plausible_event_api_endpoint, 14 | get_script, 15 | get_user_agent, 16 | get_xff, 17 | ) 18 | 19 | 20 | def script_proxy(request: HttpRequest, script_name: str): 21 | try: 22 | script_bytes, script_headers = get_script(script_name) 23 | except ValueError: 24 | return HttpResponseNotFound("Not Found") 25 | except requests.Timeout: 26 | return HttpResponse(status=504) 27 | except requests.HTTPError: 28 | return HttpResponseServerError("Internal Server Error") 29 | return HttpResponse(content=script_bytes, headers=script_headers) 30 | 31 | 32 | @csrf_exempt 33 | def event_proxy(request: HttpRequest): 34 | try: 35 | resp = requests.post( 36 | get_plausible_event_api_endpoint(), 37 | data=request.body, 38 | headers={ 39 | "content-type": "application/json", 40 | "x-forwarded-for": get_xff(request), 41 | "user-agent": get_user_agent(request), 42 | }, 43 | timeout=REQUEST_TIMEOUT, 44 | ) 45 | except requests.Timeout: 46 | return HttpResponse(status=504) 47 | 48 | return HttpResponse( 49 | content=resp.content, 50 | status=resp.status_code, 51 | headers=EVENT_HEADERS, 52 | ) 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-plausible-proxy" 3 | version = "0.5.1" 4 | homepage = "https://github.com/imankulov/django-plausible-proxy" 5 | description = "Django module to proxy requests to Plausible Analytics." 6 | authors = ["Roman Imankulov "] 7 | readme = "README.md" 8 | license = "MIT" 9 | classifiers=[ 10 | 'Development Status :: 4 - Beta', 11 | 'Intended Audience :: Developers', 12 | 'License :: OSI Approved :: MIT License', 13 | 'Natural Language :: English', 14 | 'Programming Language :: Python :: 3', 15 | 'Programming Language :: Python :: 3.8', 16 | 'Programming Language :: Python :: 3.9', 17 | 'Programming Language :: Python :: 3.10', 18 | 'Programming Language :: Python :: 3.11', 19 | ] 20 | packages = [ 21 | { include = "plausible_proxy" }, 22 | { include = "tests", format = "sdist" }, 23 | ] 24 | 25 | [tool.poetry.dependencies] 26 | python = "^3.8" 27 | Django = ">=3.2" 28 | requests = "^2" 29 | 30 | [tool.poetry.dev-dependencies] 31 | pytest = "^7" 32 | pytest-xdist = "^2.5.0" 33 | coverage = "^6.2" 34 | pytest-django = "^4.5.2" 35 | 36 | [build-system] 37 | requires = ["poetry-core>=1.0.0"] 38 | build-backend = "poetry.core.masonry.api" 39 | 40 | [tool.coverage.run] 41 | source = ["tests", "plausible_proxy"] 42 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for plausible_proxy.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def pytest_configure(): 5 | settings.configure( 6 | CACHES={ 7 | "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"} 8 | }, 9 | TEMPLATES=[ 10 | { 11 | "BACKEND": "django.template.backends.django.DjangoTemplates", 12 | } 13 | ], 14 | INSTALLED_APPS=["plausible_proxy"], 15 | ALLOWED_HOSTS=["*"], 16 | ROOT_URLCONF="tests.urlconf", 17 | ) 18 | -------------------------------------------------------------------------------- /tests/test_services.py: -------------------------------------------------------------------------------- 1 | """Tests for `plausible_proxy` package.""" 2 | 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | from plausible_proxy import send_custom_event 8 | from plausible_proxy.services import get_script, get_xff 9 | 10 | 11 | @pytest.fixture() 12 | def mock_requests(): 13 | with patch("plausible_proxy.services.requests") as requests: 14 | requests.post.return_value.ok = True 15 | yield requests 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "script_name", ["script.js", "script.hash.js", "script.hash.outbound-links.js"] 20 | ) 21 | def test_get_script_returns_javascript(script_name): 22 | resp, headers = get_script(script_name) 23 | assert resp.startswith(b"!function()") 24 | assert headers["content-type"] == "application/javascript" 25 | 26 | 27 | def test_get_script_raises_exception_on_invalid_filename(): 28 | with pytest.raises(ValueError): 29 | get_script("xxx.js") 30 | 31 | 32 | def test_get_xff_without_proxy(rf): 33 | request = rf.get("/api/event", REMOTE_ADDR="1.2.3.4") 34 | assert get_xff(request) == "1.2.3.4" 35 | 36 | 37 | def test_get_xff_with_proxy(rf): 38 | request = rf.get( 39 | "/api/event", REMOTE_ADDR="1.2.3.4", HTTP_X_FORWARDED_FOR="1.1.1.1, 2.2.2.2" 40 | ) 41 | assert get_xff(request) == "1.1.1.1, 2.2.2.2, 1.2.3.4" 42 | 43 | 44 | def test_send_custom_event_returns_true_on_success(rf, mock_requests): 45 | request = rf.get("/register", REMOTE_ADDR="1.2.3.4") 46 | status = send_custom_event( 47 | request, "Register", domain="example.com", props={"Plan": "premium"} 48 | ) 49 | assert status is True 50 | 51 | 52 | def test_send_custom_event_successful_without_domain(rf, mock_requests): 53 | request = rf.get("/register", REMOTE_ADDR="1.2.3.4", SERVER_NAME="example.com") 54 | status = send_custom_event(request, "Register", props={"Plan": "premium"}) 55 | assert status is True 56 | 57 | 58 | def test_send_custom_events_respects_explicit_remote_addr(rf, mock_requests): 59 | request = rf.get("/register", REMOTE_ADDR="1.2.3.4", SERVER_NAME="example.com") 60 | status = send_custom_event( 61 | request, "Register", domain="example.com", remote_addr="1.2.3.5" 62 | ) 63 | assert status is True 64 | 65 | headers = mock_requests.post.call_args.kwargs["headers"] 66 | assert headers["x-forwarded-for"] == "1.2.3.5" 67 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template import Context, Template 3 | 4 | 5 | def test_plausible_renders_with_data_domain_from_request(rf): 6 | request = rf.get("/", SERVER_NAME="example.com") 7 | template = Template("{% load plausible %}{% plausible %}") 8 | context = Context({"request": request}) 9 | assert ( 10 | template.render(context) 11 | == '' 12 | ) 13 | 14 | 15 | def test_plausible_uses_plausible_domain_if_defined(rf, settings): 16 | settings.PLAUSIBLE_DOMAIN = "example2.com" 17 | request = rf.get("/", SERVER_NAME="example.com") 18 | template = Template("{% load plausible %}{% plausible %}") 19 | context = Context({"request": request}) 20 | assert ( 21 | template.render(context) 22 | == '' 23 | ) 24 | 25 | 26 | def test_plausible_uses_plausible_domain_if_request_not_defined(rf, settings): 27 | settings.PLAUSIBLE_DOMAIN = "example2.com" 28 | template = Template("{% load plausible %}{% plausible %}") 29 | context = Context() 30 | assert ( 31 | template.render(context) 32 | == '' 33 | ) 34 | 35 | 36 | def test_plausible_raises_exception_if_domain_not_defined_anywhere(rf, settings): 37 | template = Template("{% load plausible %}{% plausible %}") 38 | context = Context() 39 | with pytest.raises(ValueError): 40 | template.render(context) 41 | 42 | 43 | @pytest.mark.skip( 44 | "Test fails as urlconf and urls.py content is cached. " 45 | "Still, the test works in isolation" 46 | ) 47 | def test_plausible_modifies_src_if_script_prefix_defined(rf, settings): 48 | settings.PLAUSIBLE_SCRIPT_PREFIX = "hello_world/js" 49 | request = rf.get("/", SERVER_NAME="example.com") 50 | template = Template("{% load plausible %}{% plausible %}") 51 | context = Context({"request": request}) 52 | assert template.render(context) == ( 53 | '" 55 | ) 56 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from plausible_proxy.views import event_proxy, script_proxy 2 | 3 | 4 | def test_script_proxy_proxies_request(rf): 5 | request = rf.get("/js/script.js") 6 | resp = script_proxy(request, "script.js") 7 | assert resp.content.startswith(b"!function()") 8 | assert resp.status_code == 200 9 | 10 | 11 | def test_event_proxy_proxies_request(rf): 12 | event = { 13 | "n": "pageview", 14 | "u": "https://example.com/", 15 | "d": "example.com", 16 | "r": None, 17 | "w": 123, 18 | } 19 | request = rf.post("/api/event", data=event, content_type="application/json") 20 | resp = event_proxy(request) 21 | assert resp.content == b"ok" 22 | assert resp.status_code == 202 23 | -------------------------------------------------------------------------------- /tests/urlconf.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | urlpatterns = [ 4 | path("", include("plausible_proxy.urls")), 5 | ] 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py38, py39, py310, py311, lint 4 | 5 | [gh-actions] 6 | python = 7 | 3.11: py311 8 | 3.10: py310 9 | 3.9: py39 10 | 3.8: py38 11 | 12 | [testenv] 13 | allowlist_externals = pytest 14 | passenv = * 15 | setenv = 16 | PYTHONPATH = {toxinidir} 17 | PYTHONWARNINGS = ignore 18 | commands = 19 | pytest -s tests 20 | 21 | [testenv:lint] 22 | passenv = * 23 | setenv = 24 | PYTHONPATH = {toxinidir} 25 | PYTHONWARNINGS = ignore 26 | basepython=python3.11 27 | deps=flake8 28 | commands=flake8 29 | --------------------------------------------------------------------------------