├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── tox.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── CHANGELOG ├── CONTRIBUTING ├── LICENSE ├── README.rst ├── README_kr.rst ├── manage.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── request_profiler ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── truncate_request_profiler_logs.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_profilingrecord_http_referer.py │ ├── 0003_profilingrecord_query_string.py │ ├── 0004_profilingrecord_query_count.py │ ├── 0005_alter_profilingrecord_id_alter_ruleset_id.py │ └── __init__.py ├── models.py ├── settings.py └── signals.py ├── tests ├── __init__.py ├── models.py ├── settings.py ├── templates │ ├── 404.html │ └── test.html ├── test_middleware.py ├── test_models.py ├── test_views.py ├── urls.py ├── utils.py └── views.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = request_profiler/* 3 | omit = 4 | request_profiler/tests.py 5 | request_profiler/migrations/* 6 | .tox/* 7 | 8 | 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Python / Django 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | 11 | jobs: 12 | format: 13 | name: Check formatting 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | toxenv: [fmt, lint, mypy] 18 | env: 19 | TOXENV: ${{ matrix.toxenv }} 20 | 21 | steps: 22 | - name: Check out the repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Python (3.11) 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: "3.11" 29 | 30 | - name: Install and run tox 31 | run: | 32 | pip install tox 33 | tox 34 | 35 | checks: 36 | name: Run Django checks 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | toxenv: ["django-checks"] 41 | env: 42 | TOXENV: ${{ matrix.toxenv }} 43 | 44 | steps: 45 | - name: Check out the repository 46 | uses: actions/checkout@v4 47 | 48 | - name: Set up Python (3.11) 49 | uses: actions/setup-python@v4 50 | with: 51 | python-version: "3.11" 52 | 53 | - name: Install and run tox 54 | run: | 55 | pip install tox 56 | tox 57 | 58 | test: 59 | name: Run tests 60 | runs-on: ubuntu-latest 61 | strategy: 62 | matrix: 63 | python: ["3.9", "3.10", "3.11", "3.12"] 64 | # build LTS version, next version, HEAD 65 | django: ["32", "42", "50", "main"] 66 | exclude: 67 | - python: "3.9" 68 | django: "50" 69 | - python: "3.9" 70 | django: "main" 71 | - python: "3.10" 72 | django: "main" 73 | - python: "3.11" 74 | django: "32" 75 | - python: "3.12" 76 | django: "32" 77 | 78 | env: 79 | TOXENV: django${{ matrix.django }}-py${{ matrix.python }} 80 | 81 | steps: 82 | - name: Check out the repository 83 | uses: actions/checkout@v4 84 | 85 | - name: Set up Python ${{ matrix.python }} 86 | uses: actions/setup-python@v4 87 | with: 88 | python-version: ${{ matrix.python }} 89 | 90 | - name: Install and run tox 91 | run: | 92 | pip install tox 93 | tox 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | poetry.lock 56 | test.db 57 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | # python code formatting - will amend files 3 | - repo: https://github.com/ambv/black 4 | rev: 23.10.1 5 | hooks: 6 | - id: black 7 | 8 | - repo: https://github.com/charliermarsh/ruff-pre-commit 9 | # Ruff version. 10 | rev: "v0.1.5" 11 | hooks: 12 | - id: ruff 13 | args: [--fix, --exit-non-zero-on-fix] 14 | 15 | # python static type checking 16 | - repo: https://github.com/pre-commit/mirrors-mypy 17 | rev: v1.7.0 18 | hooks: 19 | - id: mypy 20 | args: 21 | - --disallow-untyped-defs 22 | - --disallow-incomplete-defs 23 | - --check-untyped-defs 24 | - --no-implicit-optional 25 | - --ignore-missing-imports 26 | - --follow-imports=silent 27 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always", 11 | "endOfLine": "auto" 12 | } 13 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## v1.1 6 | 7 | - Added support for Django 5.0 8 | - Added support for Python 3.12 9 | - Replaced flake8, isort with ruff 10 | 11 | No code changes. 12 | 13 | ## v1.0 14 | 15 | ### Added 16 | - Add support for Python 3.11 17 | - Add new management command `truncate_request_profiler_logs` 18 | 19 | ### Fixed 20 | - Fix for #17 - content-length of StreamingHttpResponse (@FlorinaAhmeti) 21 | 22 | ### Removed 23 | - Drop support for Django <3.2 24 | - Drop support for Python 3.8 25 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We, as YunoJuno, will be building this app out to support our specific requirements. New features 4 | will be added at our convenience. 5 | 6 | All of which means, if the app isn't currently supporting _your_ use case, **get involved**! 7 | 8 | The usual rules apply: 9 | 10 | 1. If you see something that's wrong, or something that's missing, open an issue. NB please take a 11 | minute to verify that your issue is not already covered by an existing issue. 12 | 13 | 2. If you want to fix something, or add a new feature / backend etc. then: 14 | 15 | - Fork the repo 16 | - Run the tests locally: `python manage.py test test_app request_profiler` 17 | - Create a local branch (ideally named after the related issue, beginning with the issue number, 18 | e.g. `1-add-template-timings`) 19 | - Write some code 20 | - Write some tests to prove your code works 21 | - Commit it 22 | - Send a pull request 23 | 24 | Other than that we'll work it out as we go along. 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT Licence (MIT) 2 | 3 | Copyright (c) 2023 YunoJuno Limited 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.rst: -------------------------------------------------------------------------------- 1 | Django Request Profiler 2 | ======================= 3 | 4 | Project Deprecation Notice 5 | -------------------------- 6 | 7 | This project was built many moons ago, during a time where APMs were either not 8 | commonplace or too awful or expensive to use. It was meant a simple way to see 9 | how an endpoint was working and an easy data-model you could query in SQL to get 10 | percentile performance. Nowadays, we have an advanced setup internally with modern 11 | APM/observability tooling and cannot afford the time it takes to maintain this app 12 | that we no longer use. 13 | 14 | If you are interested in maintaining it, please raise an issue. Otherwise it shall 15 | be archived in due course. 16 | 17 | Thanks - YunoJuno Team. 18 | 19 | Prior Readme 20 | ------------ 21 | 22 | **This package now requires Python 3.9 and Django 3.2 and above.** 23 | 24 | A very simple request profiler for Django. 25 | 26 | Introduction 27 | ------------ 28 | 29 | Premature optimization is the root of all evil. 30 | 31 | There are a lot of very good, and complete, python and django profilers 32 | available. They can give you detailed stack traces and function call timings, 33 | output all the SQL statements that have been run, the templates that have been 34 | rendered, and the state of any / all variables along the way. These tools are 35 | great for optimisation of your application, once you have decided that the 36 | time is right. 37 | 38 | ``django-request-profiler`` is not intended to help you optimise, but to help 39 | you decide whether you need to optimise in the first place. It is complimentary. 40 | 41 | Requirements 42 | ------------ 43 | 44 | 1. Small enough to run in production 45 | 2. Able to configure profiling at runtime 46 | 3. Configurable to target specific URLs or users 47 | 4. Record basic request metadata: 48 | 49 | - Duration (request-response) 50 | - Request path, remote addr, user-agent 51 | - Response status code, content length 52 | - View function 53 | - Django user and session keys (if appropriate) 54 | - Database query count (if DEBUG=True) 55 | 56 | It doesn't need to record all the inner timing information - the goal is to have 57 | a system that can be used to monitor site response times, and to identify 58 | problem areas ahead of time. 59 | 60 | Technical details 61 | ----------------- 62 | 63 | The profiler itself runs as Django middleware, and it simply starts a timer when 64 | it first sees the request, and stops the timer when it is finished with the 65 | response. It should be installed as the first middleware in 66 | ``MIDDLEWARE_CLASSES`` in order to record the maximum duration. 67 | 68 | It hooks into the ``process_request`` method to start the timer, the 69 | ``process_view`` method to record the view function name, and the 70 | ``process_response`` method to stop the timer, record all the request 71 | information and store the instance. 72 | 73 | The profiler is controlled by adding ``RuleSet`` instances which are used to 74 | filter which requests are profiled. There can be many, overlapping, 75 | RuleSets, but if any match, the request is profiled. The RuleSet model 76 | defines two core matching methods: 77 | 78 | 1. uri_regex - in order to profile a subset of the site, you can supply a regex 79 | which is used match the incoming request path. If the url matches, the request 80 | can be profiled. 81 | 82 | 2. user_filter_type - there are three choices here - profile all users, profile 83 | only authenticated users, and profile authenticated users belonging to a given 84 | Group - e.g. create a groups called "profiling" and add anyone you want to 85 | profile. 86 | 87 | These filter properties are an AND (must pass the uri and user filter), but the 88 | rules as a group are an OR - so if a request passes all the filters in any rule, 89 | then it's profiled. 90 | 91 | These filters are pretty blunt, and there are plenty of use cases where you may 92 | want more sophisticated control over the profiling. There are two ways to do 93 | this. The first is a setting, ``REQUEST_PROFILER_GLOBAL_EXCLUDE_FUNC``, which is 94 | a function that takes a request as the single argument, and must return True or 95 | False. If it returns False, the profile is cancelled, irrespective of any rules. 96 | The primary use case for this is to exclude common requests that you are not 97 | interested in, e.g. from search engine bots, or from Admin users etc. The 98 | default for this function is to prevent admin user requests from being profiled. 99 | 100 | The second control is via the ``cancel()`` method on the ``ProfilingRecord``, 101 | which is accessible via the ``request_profile_complete`` signal. By hooking 102 | in to this signal you can add additional processing, and optionally cancel 103 | the profiler. A typical use case for this is to log requests that have 104 | exceeded a set request duration threshold. In a high volume environment you 105 | may want to, for instance, only profile a random subset of all requests. 106 | 107 | .. code:: python 108 | 109 | from django.dispatch import receiver 110 | from request_profiler.signals import request_profile_complete 111 | 112 | @receiver(request_profiler_complete) 113 | def on_request_profile_complete(sender, **kwargs): 114 | profiler = kwargs.get('instance') 115 | if profiler.elapsed > 2: 116 | # log long-running requests 117 | # NB please don't use 'print' for real - use logging 118 | print u"Long-running request warning: %s" % profiler 119 | else: 120 | # calling cancel means that it won't be saved to the db 121 | profiler.cancel() 122 | 123 | An additional scenario where you may want to use the signal is to store 124 | the profiler records async - say if you are recording every request for 125 | a short period, and you don't want to add unnecessary inline database 126 | write operations. In this case you can use the ``stop()`` method, which 127 | will prevent the middleware from saving it directly (it will only save 128 | records where ``profiler.is_running`` is true, and both ``cancel`` and 129 | ``stop`` set it to false). 130 | 131 | .. code:: python 132 | 133 | from django.dispatch import receiver 134 | from request_profiler.signals import request_profile_complete 135 | 136 | @receiver(request_profiler_complete) 137 | def on_request_profile_complete(sender, **kwargs): 138 | profiler = kwargs.get('instance') 139 | # stop the profiler to prevent it from being saved automatically 140 | profiler.stop() 141 | assert not profiler.is_running 142 | # add a job to a queue to perform the save itself 143 | queue.enqueue(profiler.save) 144 | 145 | 146 | Installation 147 | ------------ 148 | 149 | For use as the app in Django project, use pip: 150 | 151 | .. code:: shell 152 | 153 | $ pip install django-request-profiler 154 | # For hacking on the project, pull from Git: 155 | $ git pull git@github.com:yunojuno/django-request-profiler.git 156 | 157 | Tests 158 | ----- 159 | 160 | The app installer contains a test suite that can be run using the Django 161 | test runner: 162 | 163 | .. code:: shell 164 | 165 | $ pip install -r requirements.txt 166 | $ python manage.py test test_app request_profiler 167 | 168 | If you want to test coverage you'll need to add some dependencies: 169 | 170 | .. code:: shell 171 | 172 | $ pip install coverage django-coverage 173 | $ python manage.py test_coverage test_app request_profiler 174 | 175 | The tests also run using `tox `_: 176 | 177 | .. code:: shell 178 | 179 | $ pip install tox 180 | $ tox 181 | 182 | **Note: To test with a custom user model, you should override the default User model 183 | by providing a value for the AUTH_USER_MODEL (in testapp/settings) setting that references a custom model** 184 | 185 | The tests run on `Travis `_ on commits to master. 186 | 187 | Usage 188 | ----- 189 | 190 | Once installed, add the app and middleware to your project's settings file. 191 | In order to add the database tables, you should run the ``migrate`` command: 192 | 193 | .. code:: bash 194 | 195 | $ python manage.py migrate request_profiler 196 | 197 | NB the middleware must be the **first** item in ``MIDDLEWARE_CLASSES``. 198 | 199 | .. code:: python 200 | 201 | INSTALLED_APPS = ( 202 | 'django.contrib.admin', 203 | 'django.contrib.auth', 204 | 'django.contrib.contenttypes', 205 | 'django.contrib.sessions', 206 | 'django.contrib.messages', 207 | 'django.contrib.staticfiles', 208 | 'request_profiler', 209 | ) 210 | 211 | MIDDLEWARE_CLASSES = [ 212 | # this package's middleware 213 | 'request_profiler.middleware.ProfilingMiddleware', 214 | # default django middleware 215 | 'django.middleware.common.CommonMiddleware', 216 | 'django.contrib.sessions.middleware.SessionMiddleware', 217 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 218 | 'django.middleware.csrf.CsrfViewMiddleware', 219 | 'django.contrib.messages.middleware.MessageMiddleware', 220 | ] 221 | 222 | Configuration 223 | ------------- 224 | 225 | To configure the app, open the admin site, and add a new request profiler 226 | 'Rule set'. The default options will result in all non-admin requests being 227 | profiled. 228 | 229 | Licence 230 | ------- 231 | 232 | MIT (see LICENCE) 233 | -------------------------------------------------------------------------------- /README_kr.rst: -------------------------------------------------------------------------------- 1 | 장고 요청 프로파일 러 2 | =========== 3 | 4 | **이 패키지에는 이제 Python 3.9+ 및 Django 3.2+ 이상이 필요합니다. 이전 버전의 경우 Python2 분기를 참조하십시오. ** 5 | 6 | Django에 대한 매우 간단한 요청 프로파일 러입니다. 7 | 8 | 소개 9 | ------------ 10 | 11 |     조숙 한 최적화는 모든 악의 근원입니다. 12 | 13 | 매우 훌륭하고 완전한 python 및 django 프로파일 러가 많이 있습니다. 14 | 유효한. 그들은 상세한 스택 트레이스와 함수 호출 타이밍을 줄 수 있으며, 15 | 실행 된 모든 SQL 문을 출력하고, 16 | 렌더링 된 상태, 길을 따라 / 모든 변수의 상태. 이 도구들은 17 | 귀하의 응용 프로그램을 최적화하는 데 큰 도움이됩니다. 18 | 19 | 20 | ``django-request-profiler``는 최적화를 돕는 것이 아니라 도움을주기위한 것입니다. 21 | 당신은 처음부터 최적화 할 필요가 있는지 결정합니다. 그것은 무료입니다. 22 | 23 | 요구 사항 24 | ------------ 25 | 26 | 1. 프로덕션 환경에서 실행하기에 충분히 작은 크기 27 | 2. 런타임에 프로파일 링을 구성 할 수 있습니다. 28 | 3. 특정 URL 또는 사용자를 타겟팅하도록 구성 가능 29 | 4. 기본 요청 메타 데이터 기록 : 30 | 31 | - 기간 (요청 - 응답) 32 | - 요청 경로, 원격 주소, 사용자 에이전트 33 | - 응답 상태 코드, 콘텐츠 길이 34 | -보기 기능 35 | - 장고 사용자 및 세션 키 (해당되는 경우) 36 | 37 | 모든 내부 타이밍 정보를 기록 할 필요는 없습니다. 38 | 사이트 응답 시간을 모니터링하는 데 사용할 수있는 시스템 39 | 문제 영역을 사전에 확인하십시오. 40 | 41 | 기술적 세부 사항 42 | ----------------- 43 | 44 | 프로파일 러 자체는 Django 미들웨어로 실행되며 타이머가 시작됩니다. 45 | 먼저 요청을보고 타이머가 끝나면 타이머를 중지합니다. 46 | 응답. 첫 번째 미들웨어로 설치해야합니다. 47 | 최대 지속 시간을 기록하기 위해 ``MIDDLEWARE_CLASSES`` 를 사용하십시오. 48 | 49 | 타이머를 시작하기 위해 ``process_request`` 메소드에 후킹합니다. 50 | 뷰 함수 이름을 기록하는 ``process_view`` 메소드와 51 | ``process_response`` 메소드는 타이머를 멈추고, 모든 요청을 기록합니다. 52 | 정보를 저장하고 인스턴스를 저장하십시오. 53 | 54 | 프로파일 러는 "RuleSet" 인스턴스를 추가하여 제어됩니다. 55 | 요청을 프로파일 링하는 필터 많은 부분이 겹칠 수 있으며, 56 | RuleSets. 그러나 일치하는 경우 요청이 프로파일 링됩니다. RuleSet 모델 57 | 두 가지 핵심 매칭 메소드를 정의합니다. 58 | 59 | 1. uri_regex - 사이트의 하위 집합을 프로파일 링하기 위해 정규식을 제공 할 수 있습니다. 60 | 들어오는 요청 경로와 일치하는 데 사용됩니다. URL이 일치하면 요청 61 | 프로파일 링 될 수 있습니다. 62 | 63 | 2. user_filter_type - 세 가지 선택 사항이 있습니다 - 모든 사용자 프로필, 프로필 64 | 인증 된 사용자 만, 프로필에 인증 된 사용자는 주어진그룹 65 | - 예 : "프로파일 링"이라는 그룹을 만들고 원하는 사람을 추가하십시오. 66 | 67 | 68 | 이러한 필터 속성은 AND (uri 및 user 필터를 통과해야 함)이지만 69 | 그룹의 규칙은 OR입니다. 따라서 요청이 규칙의 모든 필터를 통과하면, 70 | 그리고 나서 그것은 프로파일 링됩니다. 71 | 72 | 이 필터는 꽤 깔끔하지 않고 사용 사례가 많이 있습니다. 73 | 프로파일 링에 대한보다 정교한 제어가 필요합니다. 두 가지 방법이 있습니다. 74 | 이. 첫 번째 설정은 ``REQUEST_PROFILER_GLOBAL_EXCLUDE_FUNC`` 입니다. 75 | 단일 인수로 요청을 받고 True를 반환해야하는 함수 또는 76 | 그릇된. False를 반환하면 규칙에 관계없이 프로필이 취소됩니다. 77 | 이를위한 기본 사용 사례는 사용자가 아닌 일반적인 요청을 제외하는 것입니다 78 | 에 관심이있다. 검색 엔진 봇 또는 관리자 사용자 등. 79 | 이 기능의 기본값은 관리자 사용자 요청이 프로파일되지 않도록하는 것입니다. 80 | 81 | 두 번째 컨트롤은 ``ProfilingRecord`` 에있는 ``cancel ()`` 메소드를 통해 이루어지며, 82 | 이것은 ``request_profile_complete`` 시그널을 통해 접근 가능합니다. 후크로 83 | 이 신호에 추가 처리를 추가하고 선택적으로 취소 할 수 있습니다. 84 | 프로파일 러. 일반적인 사용 사례는 다음과 같은 요청을 기록하는 것입니다. 85 | 설정된 요청 지속 시간 임계 값을 초과했습니다. 높은 볼륨 환경에서 86 | 예를 들어 모든 요청의 무작위 하위 집합 만 프로파일 링하려고 할 수 있습니다. 87 | 88 | .. code:: python 89 | 90 | from django.dispatch import receiver 91 | from request_profiler.signals import request_profile_complete 92 | 93 | @receiver(request_profiler_complete) 94 | def on_request_profile_complete(sender, **kwargs): 95 | profiler = kwargs.get('instance') 96 | if profiler.elapsed > 2: 97 | # log long-running requests 98 | # NB please don't use 'print' for real - use logging 99 | print u"Long-running request warning: %s" % profiler 100 | else: 101 | # calling cancel means that it won't be saved to the db 102 | profiler.cancel() 103 | 104 | 설치 105 | ------------ 106 | 107 | Django 프로젝트에서 앱으로 사용하려면 pip를 사용하십시오. 108 | 109 | .. code:: shell 110 | 111 | $ pip install django-request-profiler 112 | # For hacking on the project, pull from Git: 113 | $ git pull git@github.com:yunojuno/django-request-profiler.git 114 | 115 | 테스트 116 | ----- 117 | 118 | 앱 설치 프로그램에는 Django를 사용하여 실행할 수있는 테스트 스위트가 포함되어 있습니다. 119 | 테스트 주자 : 120 | 121 | .. code:: shell 122 | 123 | $ pip install -r requirements.txt 124 | $ python manage.py test test_app request_profiler 125 | 126 | 적용 범위를 테스트하려면 몇 가지 종속성을 추가해야합니다. 127 | 128 | .. code:: shell 129 | 130 | $ pip install coverage django-coverage 131 | $ python manage.py test_coverage test_app request_profiler 132 | 133 | 134 | 테스트는 `tox `_ : 135 | 136 | .. code:: shell 137 | 138 | $ pip install tox 139 | $ tox 140 | 141 | ** 참고 : 사용자 지정 사용자 모델로 테스트하려면 기본 사용자 모델을 재정의해야합니다 142 | 사용자 정의 모델을 참조하는 AUTH_USER_MODEL (testapp / settings에서) 설정 값을 제공하여 ** 143 | 144 | 테스트는`Travis `_에서 실행되어 마스터에게 커밋됩니다. 145 | 146 | 용법 147 | ----- 148 | 149 | 설치가 끝나면 앱 및 미들웨어를 프로젝트의 설정 파일에 추가하십시오. 150 | 데이터베이스 테이블을 추가하려면,``migrate`` 명령을 실행해야합니다 : 151 | 152 | .. code:: bash 153 | 154 |     $ python manage.py migrate request_profiler 155 | 156 | 주의 : 미들웨어는``MIDDLEWARE_CLASSES``의 ** 처음 ** 항목이어야합니다. 157 | 158 | .. code:: python 159 | 160 | INSTALLED_APPS = ( 161 | 'django.contrib.admin', 162 | 'django.contrib.auth', 163 | 'django.contrib.contenttypes', 164 | 'django.contrib.sessions', 165 | 'django.contrib.messages', 166 | 'django.contrib.staticfiles', 167 | 'request_profiler', 168 | ) 169 | 170 | MIDDLEWARE_CLASSES = [ 171 | # this package's middleware 172 | 'request_profiler.middleware.ProfilingMiddleware', 173 | # default django middleware 174 | 'django.middleware.common.CommonMiddleware', 175 | 'django.contrib.sessions.middleware.SessionMiddleware', 176 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 177 | 'django.middleware.csrf.CsrfViewMiddleware', 178 | 'django.contrib.messages.middleware.MessageMiddleware', 179 | ] 180 | 181 | 구성 182 | ------------- 183 | 184 | 앱을 구성하려면 관리 사이트를 열고 새 요청 프로파일 러를 추가하십시오. 185 | '규칙 집합'. 기본 옵션을 사용하면 관리자가 아닌 모든 요청이 발생합니다. 186 | 프로파일. 187 | 188 | 특허 189 | ------- 190 | 191 | MIT (라이센스 참조) 192 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict_optional=True 3 | ignore_missing_imports=True 4 | follow_imports=silent 5 | warn_redundant_casts=True 6 | warn_unused_ignores = true 7 | warn_unreachable = true 8 | disallow_untyped_defs = true 9 | disallow_incomplete_defs = true 10 | 11 | # Disable mypy for migrations 12 | [mypy-*.migrations.*] 13 | ignore_errors=True 14 | 15 | # Disable mypy for settings 16 | [mypy-*.settings.*] 17 | ignore_errors=True 18 | 19 | # Disable mypy for tests 20 | [mypy-*.tests.*] 21 | ignore_errors=True 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-request-profiler" 3 | version = "1.1" 4 | description = "A simple Django project profiler for timing HTTP requests." 5 | authors = ["YunoJuno "] 6 | license = "MIT" 7 | readme = "README.rst" 8 | homepage = "https://github.com/yunojuno/django-request-profiler" 9 | repository = "https://github.com/yunojuno/django-request-profiler" 10 | classifiers = [ 11 | "Environment :: Web Environment", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Framework :: Django", 16 | "Framework :: Django :: 3.2", 17 | "Framework :: Django :: 4.0", 18 | "Framework :: Django :: 4.1", 19 | "Framework :: Django :: 5.0", 20 | "Programming Language :: Python :: 3 :: Only", 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 | ] 26 | packages = [ 27 | { include = "request_profiler" } 28 | ] 29 | 30 | [tool.poetry.dependencies] 31 | python = "^3.9" 32 | django = "^3.2 || ^4.0 || ^5.0" 33 | 34 | [tool.poetry.dev-dependencies] 35 | black = "*" 36 | coverage = "*" 37 | mypy = "*" 38 | pre-commit = "*" 39 | pytest = "*" 40 | pytest-cov = "*" 41 | pytest-django = "*" 42 | ruff = "*" 43 | tox = "*" 44 | 45 | [build-system] 46 | requires = ["poetry>=0.12"] 47 | build-backend = "poetry.masonry.api" 48 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | python_files = test_*.py -------------------------------------------------------------------------------- /request_profiler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-profiler/38dbe3fe77e06c9aea69f373f82009b77ec8afcb/request_profiler/__init__.py -------------------------------------------------------------------------------- /request_profiler/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import ProfilingRecord, RuleSet 4 | 5 | 6 | class RuleSetAdmin(admin.ModelAdmin): 7 | list_display = ("enabled", "uri_regex", "user_filter_type", "user_group_filter") 8 | 9 | 10 | class ProfilingRecordAdmin(admin.ModelAdmin): 11 | list_display = ( 12 | "start_ts", 13 | "user", 14 | "http_method", 15 | "request_uri", 16 | "view_func_name", 17 | "query_count", 18 | "response_status_code", 19 | "duration", 20 | ) 21 | readonly_fields = ( 22 | "user", 23 | "session_key", 24 | "start_ts", 25 | "end_ts", 26 | "remote_addr", 27 | "request_uri", 28 | "query_string", 29 | "view_func_name", 30 | "http_method", 31 | "http_user_agent", 32 | "http_referer", 33 | "response_status_code", 34 | "response_content_length", 35 | "query_count", 36 | "duration", 37 | ) 38 | 39 | 40 | admin.site.register(RuleSet, RuleSetAdmin) 41 | admin.site.register(ProfilingRecord, ProfilingRecordAdmin) 42 | -------------------------------------------------------------------------------- /request_profiler/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RequestProfilerAppConfig(AppConfig): 5 | name = "request_profiler" 6 | verbose_name = "Request Profiler" 7 | default_auto_field = "django.db.models.BigAutoField" 8 | -------------------------------------------------------------------------------- /request_profiler/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-profiler/38dbe3fe77e06c9aea69f373f82009b77ec8afcb/request_profiler/management/__init__.py -------------------------------------------------------------------------------- /request_profiler/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-profiler/38dbe3fe77e06c9aea69f373f82009b77ec8afcb/request_profiler/management/commands/__init__.py -------------------------------------------------------------------------------- /request_profiler/management/commands/truncate_request_profiler_logs.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | from typing import Any 3 | 4 | from django.core.management.base import BaseCommand, CommandParser 5 | from django.utils.timezone import now as tz_now 6 | from django.utils.translation import gettext_lazy as _lazy 7 | 8 | from request_profiler.models import ProfilingRecord 9 | from request_profiler.settings import LOG_TRUNCATION_DAYS 10 | 11 | 12 | class Command(BaseCommand): 13 | help = "Truncate the profiler log after a specified number days." 14 | 15 | def add_arguments(self, parser: CommandParser) -> None: 16 | super().add_arguments(parser) 17 | parser.add_argument( 18 | "-d", 19 | "--days", 20 | dest="days", 21 | type=int, 22 | default=LOG_TRUNCATION_DAYS, 23 | help=_lazy( 24 | "Number of days after which to truncate logs. " 25 | "Defaults to REQUEST_PROFILER_LOG_TRUNCATION_DAYS." 26 | ), 27 | ) 28 | parser.add_argument( 29 | "--commit", 30 | action="store_true", 31 | help=_lazy( 32 | "Use --commit to commit the deletion. Without this the " 33 | " command is a 'dry-run'." 34 | ), 35 | ) 36 | 37 | def handle(self, *args: Any, **options: Any) -> None: 38 | self.stdout.write( 39 | f"request_profiler: truncating request_profile logs at {tz_now()}" 40 | ) 41 | if (days := options["days"]) == 0: 42 | self.stdout.write( 43 | "request_profiler: aborting truncation as truncation limit is set to 0" 44 | ) 45 | return 46 | cutoff = date.today() - timedelta(days=days) 47 | self.stdout.write(f"request_profiler: truncation cutoff: {cutoff}") 48 | logs = ProfilingRecord.objects.filter(start_ts__date__lt=cutoff) 49 | self.stdout.write(f"request_profiler: found {logs.count()} records to delete.") 50 | if not options["commit"]: 51 | self.stderr.write( 52 | "request_profiler: aborting truncation as --commit option is not set." 53 | ) 54 | return 55 | count, _ = logs.delete() 56 | self.stdout.write(f"request_profiler: deleted {count} log records.") 57 | self.stdout.write(f"request_profiler: truncation completed at {tz_now()}") 58 | -------------------------------------------------------------------------------- /request_profiler/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Any, Callable 5 | 6 | from django.contrib.auth.models import AnonymousUser 7 | from django.db.models.query import QuerySet 8 | from django.http.request import HttpRequest 9 | from django.http.response import HttpResponse 10 | from django.utils.deprecation import MiddlewareMixin 11 | 12 | from . import settings 13 | from .models import BadProfilerError, ProfilingRecord, RuleSet 14 | from .signals import request_profile_complete 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class ProfilingMiddleware(MiddlewareMixin): 20 | """ 21 | Middleware used to time request-response cycle. 22 | 23 | This middleware uses the `process_request` and `process_response` 24 | methods to both determine whether the request should be profiled 25 | at all, and then to extract various data for recording as part of 26 | the profile. 27 | 28 | The `process_request` method is used to start the profile timing; the 29 | `process_response` method is used to extract the relevant data fields, 30 | and to stop the profiler. 31 | 32 | """ 33 | 34 | def match_rules(self, request: HttpRequest, rules: QuerySet) -> list[RuleSet]: 35 | """Return subset of a list of rules that match a request.""" 36 | user = getattr(request, "user", AnonymousUser()) 37 | return [ 38 | r for r in rules if r.match_uri(request.path) and r.match_user(user) 39 | ] # noqa 40 | 41 | def match_funcs(self, request: HttpRequest) -> bool: 42 | return any(f(request) for f in settings.CUSTOM_FUNCTIONS) 43 | 44 | def process_request(self, request: HttpRequest) -> None: 45 | """Start profiling.""" 46 | # force the creation of a valid session by saving it. 47 | request.profiler = ProfilingRecord().start() 48 | if ( 49 | hasattr(request, "session") 50 | and request.session.session_key is None 51 | and settings.STORE_ANONYMOUS_SESSIONS is True 52 | ): 53 | request.session.save() 54 | request.profiler.process_request(request) 55 | 56 | def process_view( 57 | self, 58 | request: HttpRequest, 59 | view_func: Callable, 60 | view_args: Any, 61 | view_kwargs: Any, 62 | ) -> None: 63 | """Add view_func to the profiler info.""" 64 | request.profiler.process_view(request, view_func) 65 | 66 | def process_response( 67 | self, request: HttpRequest, response: HttpResponse 68 | ) -> HttpResponse: 69 | """ 70 | Add response information and save the profiler record. 71 | 72 | By the time we get here, we've run all the middleware, the view_func 73 | has been called, and we've rendered the templates. 74 | 75 | This is the last chance to override the profiler and halt the saving 76 | of the profiler record instance. This is done by sending out a signal 77 | and aborting the save if any listeners respond False. 78 | 79 | """ 80 | try: 81 | profiler = request.profiler 82 | except AttributeError: 83 | raise BadProfilerError("Request has no profiler attached.") 84 | 85 | # call the global exclude first, as there's no point continuing if this 86 | # says no. 87 | if settings.GLOBAL_EXCLUDE_FUNC(request) is False: 88 | del request.profiler 89 | return response 90 | 91 | # see if we have any matching rules 92 | matches_rules = self.match_rules(request, RuleSet.objects.live_rules()) 93 | matches_funcs = self.match_funcs(request) 94 | log_request = matches_rules or matches_funcs 95 | 96 | # clean up after ourselves 97 | if not log_request: 98 | logger.debug( 99 | "Deleting %r as request matches no live rules.", 100 | request.profiler, 101 | ) 102 | del request.profiler 103 | return response 104 | 105 | # extract properties from response for storing later 106 | profiler.process_response(response) 107 | 108 | # send signal so that receivers can intercept profiler 109 | request_profile_complete.send( 110 | sender=self.__class__, 111 | request=request, 112 | response=response, 113 | instance=profiler, 114 | ) 115 | # if any signal receivers have called cancel() on the profiler, 116 | # then we do not want to capture it. 117 | if profiler.is_running: 118 | profiler.capture() 119 | 120 | return response 121 | -------------------------------------------------------------------------------- /request_profiler/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="ProfilingRecord", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | verbose_name="ID", 19 | serialize=False, 20 | auto_created=True, 21 | primary_key=True, 22 | ), 23 | ), 24 | ("session_key", models.CharField(max_length=40, blank=True)), 25 | ("start_ts", models.DateTimeField(verbose_name="Request started at")), 26 | ("end_ts", models.DateTimeField(verbose_name="Request ended at")), 27 | ("duration", models.FloatField(verbose_name="Request duration (sec)")), 28 | ("http_method", models.CharField(max_length=10)), 29 | ("request_uri", models.URLField(verbose_name="Request path")), 30 | ("remote_addr", models.CharField(max_length=100)), 31 | ("http_user_agent", models.CharField(max_length=400)), 32 | ( 33 | "view_func_name", 34 | models.CharField(max_length=100, verbose_name="View function"), 35 | ), 36 | ("response_status_code", models.IntegerField()), 37 | ("response_content_length", models.IntegerField()), 38 | ( 39 | "user", 40 | models.ForeignKey( 41 | blank=True, 42 | to=settings.AUTH_USER_MODEL, 43 | on_delete=models.SET_NULL, 44 | null=True, 45 | ), 46 | ), 47 | ], 48 | options={}, 49 | bases=(models.Model,), 50 | ), 51 | migrations.CreateModel( 52 | name="RuleSet", 53 | fields=[ 54 | ( 55 | "id", 56 | models.AutoField( 57 | verbose_name="ID", 58 | serialize=False, 59 | auto_created=True, 60 | primary_key=True, 61 | ), 62 | ), 63 | ("enabled", models.BooleanField(default=True, db_index=True)), 64 | ( 65 | "uri_regex", 66 | models.CharField( 67 | default="", 68 | help_text="Regex used to filter by request URI.", 69 | max_length=100, 70 | verbose_name="Request path regex", 71 | blank=True, 72 | ), 73 | ), 74 | ( 75 | "user_filter_type", 76 | models.IntegerField( 77 | default=0, 78 | help_text="Filter requests by type of user.", 79 | verbose_name="User type filter", 80 | choices=[ 81 | (0, "All users (inc. None)"), 82 | (1, "Authenticated users only"), 83 | (2, "Users in a named group"), 84 | ], 85 | ), 86 | ), 87 | ( 88 | "user_group_filter", 89 | models.CharField( 90 | default="", 91 | help_text="Group used to filter users.", 92 | max_length=100, 93 | verbose_name="User group filter", 94 | blank=True, 95 | ), 96 | ), 97 | ], 98 | options={}, 99 | bases=(models.Model,), 100 | ), 101 | ] 102 | -------------------------------------------------------------------------------- /request_profiler/migrations/0002_profilingrecord_http_referer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [("request_profiler", "0001_initial")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profilingrecord", 13 | name="http_referer", 14 | field=models.CharField(default="", max_length=400), 15 | preserve_default=True, 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /request_profiler/migrations/0003_profilingrecord_query_string.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.12 on 2019-03-27 14:10 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [("request_profiler", "0002_profilingrecord_http_referer")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="profilingrecord", 14 | name="query_string", 15 | field=models.TextField(null=False, blank=True, verbose_name="Query string"), 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /request_profiler/migrations/0004_profilingrecord_query_count.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-13 10:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [("request_profiler", "0003_profilingrecord_query_string")] 8 | 9 | operations = [ 10 | migrations.AddField( 11 | model_name="profilingrecord", 12 | name="query_count", 13 | field=models.IntegerField( 14 | help_text="Number of database queries logged during request.", 15 | blank=True, 16 | null=True, 17 | ), 18 | ) 19 | ] 20 | -------------------------------------------------------------------------------- /request_profiler/migrations/0005_alter_profilingrecord_id_alter_ruleset_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2022-08-19 04:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("request_profiler", "0004_profilingrecord_query_count"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="profilingrecord", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="ruleset", 21 | name="id", 22 | field=models.BigAutoField( 23 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /request_profiler/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | # request_profiler.migrations package 2 | -------------------------------------------------------------------------------- /request_profiler/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import re 5 | from typing import Any, Callable 6 | 7 | from django.conf import settings as django_settings 8 | from django.contrib.auth.models import AnonymousUser 9 | from django.core.cache import cache 10 | from django.core.exceptions import ValidationError 11 | from django.db import connection, models 12 | from django.db.models.query import QuerySet 13 | from django.http import HttpRequest, HttpResponse, StreamingHttpResponse 14 | from django.utils import timezone 15 | from django.utils.translation import gettext_lazy as _lazy 16 | 17 | from . import settings 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class BadProfilerError(ValueError): 23 | pass 24 | 25 | 26 | class RuleSetQuerySet(models.query.QuerySet): 27 | """Custom QuerySet for RuleSet instances.""" 28 | 29 | def live_rules(self) -> QuerySet: 30 | """Return enabled rules.""" 31 | rulesets = cache.get(settings.RULESET_CACHE_KEY) 32 | if rulesets is None: 33 | rulesets = self.filter(enabled=True) 34 | cache.set( 35 | settings.RULESET_CACHE_KEY, rulesets, settings.RULESET_CACHE_TIMEOUT 36 | ) 37 | return rulesets 38 | 39 | 40 | class RuleSet(models.Model): 41 | """Set of rules to match a URI and/or User.""" 42 | 43 | # property used to determine how to filter users 44 | USER_FILTER_ALL = 0 45 | USER_FILTER_AUTH = 1 46 | USER_FILTER_GROUP = 2 47 | 48 | USER_FILTER_CHOICES = ( 49 | (USER_FILTER_ALL, "All users (inc. None)"), 50 | (USER_FILTER_AUTH, "Authenticated users only"), 51 | (USER_FILTER_GROUP, "Users in a named group"), 52 | ) 53 | 54 | enabled = models.BooleanField(default=True, db_index=True) 55 | uri_regex = models.CharField( 56 | blank=True, 57 | default="", 58 | max_length=100, 59 | help_text="Regex used to filter by request URI.", 60 | verbose_name="Request path regex", 61 | ) 62 | user_filter_type = models.IntegerField( 63 | default=0, 64 | choices=USER_FILTER_CHOICES, 65 | help_text="Filter requests by type of user.", 66 | verbose_name="User type filter", 67 | ) 68 | user_group_filter = models.CharField( 69 | blank=True, 70 | default="", 71 | max_length=100, 72 | help_text="Group used to filter users.", 73 | verbose_name="User group filter", 74 | ) 75 | # use the custom model manager 76 | objects = RuleSetQuerySet.as_manager() 77 | 78 | def __str__(self) -> str: 79 | return "Profiling rule #{}".format(self.pk) 80 | 81 | @property 82 | def has_group_filter(self) -> bool: 83 | return len(self.user_group_filter.strip()) > 0 84 | 85 | def clean(self) -> None: 86 | """Ensure that user_filter_group and user_filter_type values are appropriate.""" 87 | if self.has_group_filter and self.user_filter_type != RuleSet.USER_FILTER_GROUP: 88 | raise ValidationError( 89 | "User filter type must be 'group' if you specify a group." 90 | ) 91 | if ( 92 | self.user_filter_type == RuleSet.USER_FILTER_GROUP 93 | and not self.has_group_filter 94 | ): 95 | raise ValidationError( 96 | "You must specify a group if the filter type is 'group'." 97 | ) 98 | # check regex is a valid regex 99 | try: 100 | re.search(self.uri_regex, "/") 101 | except re.error as ex: 102 | raise ValidationError(f"Invalid uri_regex (r'{self.uri_regex}'): {ex}") 103 | 104 | def match_uri(self, request_uri: str) -> bool: 105 | """ 106 | Return True if there is a uri_regex and it matches. 107 | 108 | Args: 109 | request_uri: the HttpRequest.build_absolute_uri(), used 110 | to match against all the uri_regex. 111 | 112 | Returns True if there is a uri_regex and it matches, or if there 113 | there is no uri_regex, in which the match is implicit. 114 | 115 | """ 116 | regex = self.uri_regex.strip() 117 | if regex == "": 118 | return True 119 | try: 120 | return re.search(regex, request_uri) is not None 121 | except re.error: 122 | logger.exception("Regex error running request profiler.") 123 | return False 124 | 125 | def match_user(self, user: django_settings.AUTH_USER_MODEL) -> bool: 126 | """Return True if the user passes the various user filters.""" 127 | # treat no user (i.e. has not been added) as AnonymousUser() 128 | user = user or AnonymousUser() 129 | 130 | if self.user_filter_type == RuleSet.USER_FILTER_ALL: 131 | return True 132 | 133 | if self.user_filter_type == RuleSet.USER_FILTER_AUTH: 134 | return user.is_authenticated 135 | 136 | if self.user_filter_type == RuleSet.USER_FILTER_GROUP: 137 | group = self.user_group_filter.strip() 138 | return user.groups.filter(name__iexact=group).exists() 139 | 140 | # if we're still going, then it's a no. it's also an invalid 141 | # user_filter_type, so we may want to think about a warning 142 | return False 143 | 144 | 145 | class ProfilingRecord(models.Model): 146 | """Record of a request and its response.""" 147 | 148 | user = models.ForeignKey( 149 | django_settings.AUTH_USER_MODEL, 150 | on_delete=models.SET_NULL, 151 | null=True, 152 | blank=True, 153 | ) 154 | session_key = models.CharField(blank=True, max_length=40) 155 | start_ts = models.DateTimeField(verbose_name="Request started at") 156 | end_ts = models.DateTimeField(verbose_name="Request ended at") 157 | duration = models.FloatField(verbose_name="Request duration (sec)") 158 | http_method = models.CharField(max_length=10) 159 | request_uri = models.URLField(verbose_name="Request path") 160 | query_string = models.TextField(null=False, blank=True, verbose_name="Query string") 161 | remote_addr = models.CharField(max_length=100) 162 | http_user_agent = models.CharField(max_length=400) 163 | http_referer = models.CharField(max_length=400, default="") 164 | view_func_name = models.CharField(max_length=100, verbose_name="View function") 165 | response_status_code = models.IntegerField() 166 | response_content_length = models.IntegerField() 167 | query_count = models.IntegerField( 168 | help_text="Number of database queries logged during request.", 169 | blank=True, 170 | null=True, 171 | ) 172 | 173 | def __str__(self) -> str: 174 | return "Profiling record #{}".format(self.pk) 175 | 176 | def __init__(self, *args: Any, **kwargs: Any) -> None: 177 | self.is_running = False 178 | super().__init__(*args, **kwargs) 179 | 180 | def save(self, *args: Any, **kwargs: Any) -> ProfilingRecord: 181 | super().save(*args, **kwargs) 182 | return self 183 | 184 | @property 185 | def elapsed(self) -> float: 186 | """Time (in seconds) elapsed so far.""" 187 | self.check_is_running() 188 | return (timezone.now() - self.start_ts).total_seconds() 189 | 190 | def process_request(self, request: HttpRequest) -> None: 191 | """Extract values from HttpRequest and store locally.""" 192 | self.request = request 193 | self.http_method = request.method 194 | self.request_uri = request.path 195 | self.query_string = request.META.get("QUERY_STRING", "") 196 | self.http_user_agent = request.META.get("HTTP_USER_AGENT", "")[:400] 197 | # we care about the domain more than the URL itself, so truncating 198 | # doesn't lose much useful information 199 | self.http_referer = request.META.get("HTTP_REFERER", "")[:400] 200 | # X-Forwarded-For is used by convention when passing through 201 | # load balancers etc., as the REMOTE_ADDR is rewritten in transit 202 | self.remote_addr = ( 203 | request.META.get("HTTP_X_FORWARDED_FOR") 204 | if "HTTP_X_FORWARDED_FOR" in request.META 205 | else request.META.get("REMOTE_ADDR") 206 | ) 207 | # these two require middleware, so may not exist 208 | if hasattr(request, "session"): 209 | self.session_key = request.session.session_key or "" 210 | # NB you can't store AnonymouseUsers, so don't bother trying 211 | if hasattr(request, "user") and request.user.is_authenticated: 212 | self.user = request.user 213 | 214 | def _extract_view_func_name(self, view_func: Callable) -> str: 215 | # the View.as_view() method sets this 216 | if hasattr(view_func, "view_class"): 217 | return view_func.view_class.__name__ 218 | return ( 219 | view_func.__name__ 220 | if hasattr(view_func, "__name__") 221 | else view_func.__class__.__name__ 222 | ) 223 | 224 | def _content_length(self, response: HttpResponse) -> int: 225 | """Return the response content length.""" 226 | if isinstance(response, StreamingHttpResponse): 227 | return -1 228 | return len(response.content) 229 | 230 | def process_view(self, request: HttpRequest, view_func: Callable) -> None: 231 | """Handle the process_view middleware event.""" 232 | self.view_func_name = self._extract_view_func_name(view_func) 233 | 234 | def process_response(self, response: HttpResponse) -> None: 235 | """Extract values from HttpResponse and store locally.""" 236 | self.response = response 237 | self.response_status_code = response.status_code 238 | self.response_content_length = self._content_length(response) 239 | 240 | def check_is_running(self) -> ProfilingRecord: 241 | """Raise BadProfilerError if profile is not running.""" 242 | if self.start_ts is None: 243 | raise BadProfilerError(_lazy("RequestProfiler has not started.")) 244 | if not self.is_running: 245 | raise BadProfilerError(_lazy("RequestProfiler is no longer running.")) 246 | return self 247 | 248 | def start(self) -> ProfilingRecord: 249 | """Set start_ts from current datetime.""" 250 | self.is_running = True 251 | self.start_ts = timezone.now() 252 | self.end_ts = None 253 | self.duration = None 254 | self.query_count = 0 255 | self._query_count = len(connection.queries) 256 | self._force_debug_cursor = connection.force_debug_cursor 257 | connection.force_debug_cursor = settings.FORCE_DEBUG_CURSOR 258 | return self 259 | 260 | def stop(self) -> ProfilingRecord: 261 | """Set end_ts and duration from current datetime.""" 262 | self.check_is_running() 263 | self.end_ts = timezone.now() 264 | self.duration = (self.end_ts - self.start_ts).total_seconds() 265 | self.query_count = len(connection.queries) - self._query_count 266 | connection.force_debug_cursor = self._force_debug_cursor 267 | if hasattr(self, "response"): 268 | self.response["X-Profiler-Duration"] = self.duration 269 | self.is_running = False 270 | return self 271 | 272 | def cancel(self) -> ProfilingRecord: 273 | """Cancel the profile by setting is_running to False.""" 274 | self.start_ts = None 275 | self.end_ts = None 276 | self.duration = None 277 | self.is_running = False 278 | return self 279 | 280 | def capture(self) -> ProfilingRecord: 281 | """Call stop and save.""" 282 | return self.check_is_running().stop().save() 283 | -------------------------------------------------------------------------------- /request_profiler/settings.py: -------------------------------------------------------------------------------- 1 | # models definitions for request_profiler 2 | from typing import Callable 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.http import HttpRequest 7 | 8 | # cache key used to store enabled rulesets. 9 | RULESET_CACHE_KEY = str( 10 | getattr( 11 | settings, "REQUEST_PROFILER_RULESET_CACHE_KEY", "request_profiler__rulesets" 12 | ) 13 | ) # noqa 14 | 15 | # how long to cache them for - defaults to 10s 16 | RULESET_CACHE_TIMEOUT = int( 17 | getattr(settings, "REQUEST_PROFILER_RULESET_CACHE_TIMEOUT", 10) 18 | ) # noqa 19 | 20 | # set to True to force the use of a debug cursor so that queries can be counted 21 | # use with caution - this will force the db.connection to store queries 22 | FORCE_DEBUG_CURSOR = bool( 23 | getattr(settings, "REQUEST_PROFILER_FORCE_DEBUG_CURSOR", False) 24 | ) 25 | 26 | # This is a function that can be used to override all rules to exclude requests 27 | # from profiling e.g. you can use this to ignore staff, or search engine bots, etc. 28 | GLOBAL_EXCLUDE_FUNC = getattr( 29 | settings, 30 | "REQUEST_PROFILER_GLOBAL_EXCLUDE_FUNC", 31 | lambda r: not (hasattr(r, "user") and r.user.is_staff), 32 | ) 33 | 34 | # if True (default) then store sessions even for anonymous users 35 | STORE_ANONYMOUS_SESSIONS = bool( 36 | getattr(settings, "REQUEST_PROFILER_STORE_ANONYMOUS_SESSIONS", True) 37 | ) # noqa 38 | 39 | 40 | # List of functions that take a HttpRequest and return bool 41 | CUSTOM_FUNCTIONS: list[Callable[[HttpRequest], bool]] = getattr( 42 | settings, "REQUEST_PROFILER_CUSTOM_FUNCTIONS", [] 43 | ) # noqa 44 | 45 | 46 | # catch old misspellings 47 | if hasattr(settings, "REQUEST_PROFILER_STORE_ANONYMOUS_SESSIONS"): 48 | raise ImproperlyConfigured( 49 | "Please rename 'REQUEST_PROFILE_STORE_ANONYMOUS_SESSIONS' to " 50 | "'REQUEST_PROFILER_STORE_ANONYMOUS_SESSIONS'." 51 | ) 52 | 53 | 54 | # The number of days after which to delete logs - defaults to 0, which 55 | # means do not delete. 56 | LOG_TRUNCATION_DAYS = int(getattr(settings, "REQUEST_PROFILER_LOG_TRUNCATION_DAYS", 0)) 57 | -------------------------------------------------------------------------------- /request_profiler/signals.py: -------------------------------------------------------------------------------- 1 | # signal definitions for request_profiler 2 | from django.dispatch import Signal 3 | 4 | # Signal sent after profile data has been captured, but before it is 5 | # saved. This signal can be used to cancel the profiling by calling the 6 | # instance.cancel() method, which sets an internal property telling the 7 | # instance not to save itself when capture() is called. 8 | # providing_args=["request", "response", "instance"] 9 | request_profile_complete = Signal() 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-profiler/38dbe3fe77e06c9aea69f373f82009b77ec8afcb/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractBaseUser, BaseUserManager 2 | from django.core import validators 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | 7 | class CustomUserManager(BaseUserManager): 8 | # override this method 9 | def _create_user( 10 | self, 11 | mobile_number, 12 | password, 13 | is_staff=False, 14 | is_superuser=False, 15 | **extra_fields 16 | ): 17 | date_joined = timezone.now() 18 | if not mobile_number: 19 | raise ValueError("The given mobile number must be set") 20 | 21 | user = self.model( 22 | mobile_number=mobile_number, 23 | is_staff=is_staff, 24 | is_active=True, 25 | is_superuser=is_superuser, 26 | date_joined=date_joined, 27 | **extra_fields 28 | ) 29 | 30 | user.set_password(password) 31 | user.save(using=self._db) 32 | return user 33 | 34 | def create_user(self, mobile_number, password=None, **extra_fields): 35 | extra_fields.setdefault("is_staff", False) 36 | extra_fields.setdefault("is_superuser", False) 37 | return self._create_user(mobile_number, password, **extra_fields) 38 | 39 | def create_superuser(self, mobile_number, password, **extra_fields): 40 | return self._create_user(mobile_number, password, True, True, **extra_fields) 41 | 42 | 43 | class CustomUser(AbstractBaseUser): 44 | mobile_number = models.CharField( 45 | unique=True, 46 | max_length=32, 47 | validators=[ 48 | validators.RegexValidator( 49 | r"^\+\d+-\d+$", 50 | ( 51 | "Enter a valid mobile number. " 52 | "mobile number format is +-" 53 | ), 54 | "invalid", 55 | ) 56 | ], 57 | ) 58 | 59 | name = models.CharField(max_length=32, blank=True, default="") 60 | email = models.EmailField(unique=True, null=True) 61 | is_active = models.BooleanField("active", default=True) 62 | is_staff = models.BooleanField("staff status", default=False) 63 | date_joined = models.DateTimeField("date joined", default=timezone.now) 64 | 65 | USERNAME_FIELD = "mobile_number" 66 | REQUIRED_FIELDS = ["date_joined"] 67 | 68 | objects = CustomUserManager() 69 | 70 | class Meta: 71 | app_label = "tests" 72 | 73 | def get_full_name(self): 74 | return self.mobile_number 75 | 76 | def get_short_name(self): 77 | return self.mobile_number 78 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = True 5 | 6 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "test.db"}} 7 | 8 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 9 | 10 | INSTALLED_APPS = ( 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.sessions", 15 | "django.contrib.messages", 16 | "django.contrib.staticfiles", 17 | "request_profiler", 18 | "tests", 19 | ) 20 | 21 | MIDDLEWARE = [ 22 | # default django middleware 23 | "django.contrib.sessions.middleware.SessionMiddleware", 24 | "django.middleware.common.CommonMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | # this package's middleware 29 | "request_profiler.middleware.ProfilingMiddleware", 30 | ] 31 | 32 | PROJECT_DIR = path.abspath(path.join(path.dirname(__file__))) 33 | 34 | TEMPLATES = [ 35 | { 36 | "BACKEND": "django.template.backends.django.DjangoTemplates", 37 | "DIRS": [path.join(PROJECT_DIR, "templates")], 38 | "APP_DIRS": True, 39 | "OPTIONS": { 40 | "context_processors": [ 41 | "django.contrib.messages.context_processors.messages", 42 | "django.contrib.auth.context_processors.auth", 43 | "django.template.context_processors.request", 44 | ] 45 | }, 46 | } 47 | ] 48 | 49 | 50 | STATIC_URL = "/static/" 51 | 52 | SECRET_KEY = "secret" # noqa: S105 53 | 54 | LOGGING = { 55 | "version": 1, 56 | "disable_existing_loggers": False, 57 | "formatters": {"simple": {"format": "%(levelname)s %(message)s"}}, 58 | "handlers": { 59 | "console": { 60 | "level": "DEBUG", 61 | "class": "logging.StreamHandler", 62 | "formatter": "simple", 63 | } 64 | }, 65 | "loggers": { 66 | "": {"handlers": ["console"], "propagate": True, "level": "ERROR"}, 67 | # 'django': { 68 | # 'handlers': ['console'], 69 | # 'propagate': True, 70 | # 'level': 'WARNING', 71 | # }, 72 | # 'request_profiler': { 73 | # 'handlers': ['console'], 74 | # 'propagate': True, 75 | # 'level': 'WARNING', 76 | # }, 77 | }, 78 | } 79 | 80 | ROOT_URLCONF = "tests.urls" 81 | 82 | # turn off caching for tests 83 | REQUEST_PROFILER_RULESET_CACHE_TIMEOUT = 0 84 | 85 | # AUTH_USER_MODEL = 'tests.CustomUser' 86 | -------------------------------------------------------------------------------- /tests/templates/404.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunojuno/django-request-profiler/38dbe3fe77e06c9aea69f373f82009b77ec8afcb/tests/templates/404.html -------------------------------------------------------------------------------- /tests/templates/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test 4 | 5 | 6 | This is a test. 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.auth.models import AnonymousUser, Group, User 3 | from django.db import connection 4 | from django.db.migrations.autodetector import MigrationAutodetector 5 | from django.db.migrations.executor import MigrationExecutor 6 | from django.db.migrations.state import ProjectState 7 | from django.test import RequestFactory, TestCase 8 | 9 | from request_profiler import settings 10 | from request_profiler.middleware import ProfilingMiddleware, request_profile_complete 11 | from request_profiler.models import ProfilingRecord, RuleSet 12 | 13 | from .models import CustomUser 14 | from .utils import skipIfCustomUser, skipIfDefaultUser 15 | 16 | 17 | def dummy_view_func(request, **kwargs): 18 | """Fake function to pass into the process_view method.""" 19 | pass 20 | 21 | 22 | class DummyView(object): 23 | """Fake callable object to pass into the process_view method.""" 24 | 25 | def __call__(self, request, **kwargs): 26 | pass 27 | 28 | 29 | class MockSession: 30 | def __init__(self, session_key): 31 | self.session_key = session_key 32 | 33 | 34 | class MockResponse: 35 | def __init__(self, status_code): 36 | self.status_code = status_code 37 | self.content = "Hello, World!" 38 | self.values = {} 39 | 40 | def __getitem__(self, key): 41 | return self.values[key] 42 | 43 | def __setitem__(self, key, value): 44 | self.values[key] = value 45 | 46 | 47 | @skipIfCustomUser 48 | class ProfilingMiddlewareDefaultUserTests(TestCase): 49 | def setUp(self): 50 | self.factory = RequestFactory() 51 | self.anon = AnonymousUser() 52 | self.bob = User.objects.create_user("bob") 53 | self.god = User.objects.create_superuser("god", "iamthelaw", "") 54 | self.test_group = Group(name="test") 55 | self.test_group.save() 56 | # remove any existing external signal listeners 57 | request_profile_complete.receivers = [] 58 | 59 | def test_match_rules(self): 60 | # rule1 - to match all users 61 | r1 = RuleSet() 62 | self.assertTrue(r1.match_user(self.anon)) 63 | 64 | request = self.factory.get("/") 65 | request.user = self.anon 66 | self.assertTrue(r1.match_uri, request.path) 67 | 68 | middleware = ProfilingMiddleware(get_response=lambda r: None) 69 | self.assertEqual(middleware.match_rules(request, [r1]), [r1]) 70 | 71 | # now change the uri_regex so we no longer get a match 72 | r1.uri_regex = "^xyz$" 73 | self.assertEqual(middleware.match_rules(request, [r1]), []) 74 | 75 | # now change the user_groups so we no longer get a match 76 | request.user = self.bob 77 | r1.uri_regex = "" 78 | r1.user_filter_type = RuleSet.USER_FILTER_GROUP 79 | r1.user_group_filter = "test" 80 | self.assertEqual(middleware.match_rules(request, [r1]), []) 81 | # add bob to the group 82 | self.bob.groups.add(self.test_group) 83 | self.assertEqual(middleware.match_rules(request, [r1]), [r1]) 84 | 85 | def test_process_request(self): 86 | request = self.factory.get("/") 87 | ProfilingMiddleware(get_response=lambda r: None).process_request(request) 88 | # this implicitly checks that the profile is attached, 89 | # and that start() has been called. 90 | self.assertIsNotNone(request.profiler.elapsed) 91 | 92 | def test_process_view(self): 93 | request = self.factory.get("/") 94 | request.profiler = ProfilingRecord() 95 | middleware = ProfilingMiddleware(get_response=lambda r: None) 96 | middleware.process_view(request, dummy_view_func, [], {}) 97 | self.assertEqual(request.profiler.view_func_name, "dummy_view_func") 98 | 99 | def test_process_view__as_callable_object(self): 100 | request = self.factory.get("/") 101 | request.profiler = ProfilingRecord() 102 | middleware = ProfilingMiddleware(get_response=lambda r: None) 103 | middleware.process_view(request, DummyView(), [], {}) 104 | self.assertEqual(request.profiler.view_func_name, "DummyView") 105 | 106 | def test_process_response(self): 107 | request = self.factory.get("/") 108 | middleware = ProfilingMiddleware(get_response=lambda r: None) 109 | with self.assertRaises(ValueError): 110 | middleware.process_response(request, None) 111 | 112 | # try no matching rules 113 | request.profiler = ProfilingRecord().start() 114 | response = middleware.process_response(request, MockResponse(200)) 115 | self.assertEqual(response.status_code, 200) 116 | self.assertFalse(hasattr(request, "profiler")) 117 | 118 | # try matching a rule, and checking response values 119 | r1 = RuleSet() 120 | r1.save() 121 | request.profiler = ProfilingRecord().start() 122 | response = middleware.process_response(request, MockResponse(200)) 123 | self.assertIsNotNone(response) 124 | self.assertTrue(request.profiler.response_status_code, response.status_code) 125 | self.assertTrue(response["X-Profiler-Duration"], request.profiler.duration) 126 | 127 | def test_process_response_signal_cancellation(self): 128 | request = self.factory.get("/") 129 | request.profiler = ProfilingRecord().start() 130 | middleware = ProfilingMiddleware(get_response=lambda r: None) 131 | 132 | # try matching a rule, anc checking response values 133 | r1 = RuleSet() 134 | r1.save() 135 | 136 | self.signal_received = False 137 | 138 | def on_request_profile_complete(sender, **kwargs): 139 | self.signal_received = True 140 | kwargs.get("instance").cancel() 141 | 142 | request_profile_complete.connect(on_request_profile_complete) 143 | middleware.process_response(request, MockResponse(200)) 144 | # because we returned False from the signal receiver, 145 | # we should have stopped profiling. 146 | self.assertTrue(self.signal_received) 147 | # because we called cancel(), the record is not saved. 148 | self.assertIsNone(request.profiler.id) 149 | 150 | def test_global_exclude_function(self): 151 | # set the func to ignore everything 152 | RuleSet().save() 153 | request = self.factory.get("/") 154 | request.profiler = ProfilingRecord().start() 155 | middleware = ProfilingMiddleware(get_response=lambda r: None) 156 | # process normally, record is saved. 157 | middleware.process_response(request, MockResponse(200)) 158 | self.assertIsNotNone(request.profiler.id) 159 | 160 | # NB for some reason (prb. due to imports, the standard 161 | # 'override_settings' decorator doesn't work here.) 162 | settings.GLOBAL_EXCLUDE_FUNC = lambda x: False 163 | request.profiler = ProfilingRecord().start() 164 | # process now, and profiler is cancelled 165 | middleware.process_response(request, MockResponse(200)) 166 | self.assertFalse(hasattr(request, "profiler")) 167 | settings.GLOBAL_EXCLUDE_FUNC = lambda x: True 168 | 169 | 170 | @skipIfDefaultUser 171 | class ProfilingMiddlewareCustomUserTests(TestCase): 172 | def setUp(self): 173 | self.factory = RequestFactory() 174 | self.anon = AnonymousUser() 175 | self.bob = CustomUser.objects.create_user( 176 | mobile_number="+886-999888777", password="pass11" 177 | ) 178 | self.god = CustomUser.objects.create_superuser( 179 | mobile_number="+886-999888000", password="pass11" 180 | ) 181 | self.test_group = Group(name="test") 182 | self.test_group.save() 183 | # remove any existing external signal listeners 184 | request_profile_complete.receivers = [] 185 | 186 | def test_match_rules(self): 187 | # rule1 - to match all users 188 | r1 = RuleSet() 189 | self.assertTrue(r1.match_user(self.anon)) 190 | 191 | request = self.factory.get("/") 192 | request.user = self.anon 193 | self.assertTrue(r1.match_uri, request.path) 194 | 195 | middleware = ProfilingMiddleware(get_response=lambda r: None) 196 | self.assertEqual(middleware.match_rules(request, [r1]), [r1]) 197 | 198 | # now change the uri_regex so we no longer get a match 199 | r1.uri_regex = "^xyz$" 200 | self.assertEqual(middleware.match_rules(request, [r1]), []) 201 | 202 | # now change the user_groups so we no longer get a match 203 | request.user = self.bob 204 | r1.uri_regex = "" 205 | r1.user_filter_type = RuleSet.USER_FILTER_GROUP 206 | r1.user_group_filter = "test" 207 | self.assertEqual(middleware.match_rules(request, [r1]), []) 208 | # add bob to the group 209 | self.bob.groups.add(self.test_group) 210 | self.assertEqual(middleware.match_rules(request, [r1]), [r1]) 211 | 212 | def test_process_request(self): 213 | request = self.factory.get("/") 214 | middleware = ProfilingMiddleware(get_response=lambda r: None) 215 | middleware.process_request(request) 216 | # this implicitly checks that the profile is attached, 217 | # and that start() has been called. 218 | self.assertIsNotNone(request.profiler.elapsed) 219 | 220 | def test_process_view(self): 221 | request = self.factory.get("/") 222 | request.profiler = ProfilingRecord() 223 | middleware = ProfilingMiddleware(get_response=lambda r: None) 224 | middleware.process_view(request, dummy_view_func, [], {}) 225 | self.assertEqual(request.profiler.view_func_name, "dummy_view_func") 226 | 227 | def test_process_view__as_callable_object(self): 228 | request = self.factory.get("/") 229 | request.profiler = ProfilingRecord() 230 | middleware = ProfilingMiddleware(get_response=lambda r: None) 231 | middleware.process_view(request, DummyView(), [], {}) 232 | self.assertEqual(request.profiler.view_func_name, "DummyView") 233 | 234 | def test_process_response(self): 235 | request = self.factory.get("/") 236 | middleware = ProfilingMiddleware(get_response=lambda r: None) 237 | with self.assertRaises(AssertionError): 238 | middleware.process_response(request, None) 239 | 240 | # try no matching rules 241 | request.profiler = ProfilingRecord().start() 242 | response = middleware.process_response(request, MockResponse(200)) 243 | self.assertEqual(response.status_code, 200) 244 | self.assertFalse(hasattr(request, "profiler")) 245 | 246 | # try matching a rule, and checking response values 247 | r1 = RuleSet() 248 | r1.save() 249 | request.profiler = ProfilingRecord().start() 250 | response = middleware.process_response(request, MockResponse(200)) 251 | self.assertIsNotNone(response) 252 | self.assertTrue(request.profiler.response_status_code, response.status_code) 253 | self.assertTrue(response["X-Profiler-Duration"], request.profiler.duration) 254 | 255 | def test_process_response_signal_cancellation(self): 256 | request = self.factory.get("/") 257 | request.profiler = ProfilingRecord().start() 258 | middleware = ProfilingMiddleware(get_response=lambda r: None) 259 | 260 | # try matching a rule, anc checking response values 261 | r1 = RuleSet() 262 | r1.save() 263 | 264 | self.signal_received = False 265 | 266 | def on_request_profile_complete(sender, **kwargs): 267 | self.signal_received = True 268 | kwargs.get("instance").cancel() 269 | 270 | request_profile_complete.connect(on_request_profile_complete) 271 | middleware.process_response(request, MockResponse(200)) 272 | # because we returned False from the signal receiver, 273 | # we should have stopped profiling. 274 | self.assertTrue(self.signal_received) 275 | # because we called cancel(), the record is not saved. 276 | self.assertIsNone(request.profiler.id) 277 | 278 | def test_global_exclude_function(self): 279 | # set the func to ignore everything 280 | RuleSet().save() 281 | request = self.factory.get("/") 282 | request.profiler = ProfilingRecord().start() 283 | middleware = ProfilingMiddleware(get_response=lambda r: None) 284 | # process normally, record is saved. 285 | middleware.process_response(request, MockResponse(200)) 286 | self.assertIsNotNone(request.profiler.id) 287 | 288 | # NB for some reason (prb. due to imports, the standard 289 | # 'override_settings' decorator doesn't work here.) 290 | settings.GLOBAL_EXCLUDE_FUNC = lambda x: False 291 | request.profiler = ProfilingRecord().start() 292 | # process now, and profiler is cancelled 293 | middleware.process_response(request, MockResponse(200)) 294 | self.assertFalse(hasattr(request, "profiler")) 295 | settings.GLOBAL_EXCLUDE_FUNC = lambda x: True 296 | 297 | 298 | class MigrationsTests(TestCase): 299 | def test_for_missing_migrations(self): 300 | """Checks if there're models changes which aren't reflected in migrations.""" 301 | migrations_loader = MigrationExecutor(connection).loader 302 | migrations_detector = MigrationAutodetector( 303 | from_state=migrations_loader.project_state(), 304 | to_state=ProjectState.from_apps(apps), 305 | ) 306 | if migrations_detector.changes(graph=migrations_loader.graph): 307 | self.fail( 308 | "Your models have changes that are not yet reflected " 309 | "in a migration. You should add them now." 310 | ) 311 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.contrib.auth.models import AnonymousUser, Group, User 4 | from django.core.cache import cache 5 | from django.core.exceptions import ValidationError 6 | from django.db import connection 7 | from django.http import HttpResponse, StreamingHttpResponse 8 | from django.test import RequestFactory, TestCase 9 | 10 | from request_profiler import settings 11 | from request_profiler.models import BadProfilerError, ProfilingRecord, RuleSet 12 | 13 | from .models import CustomUser 14 | from .utils import skipIfCustomUser, skipIfDefaultUser 15 | 16 | 17 | class MockSession: 18 | def __init__(self, session_key): 19 | self.session_key = session_key 20 | 21 | 22 | class MockResponse: 23 | def __init__(self, status_code): 24 | self.status_code = status_code 25 | self.content = "Hello, World!" 26 | self.type = HttpResponse 27 | self.values = {} 28 | 29 | def __getitem__(self, key): 30 | return self.values[key] 31 | 32 | def __setitem__(self, key, value): 33 | self.values[key] = value 34 | 35 | 36 | class RuleSetQuerySetTests(TestCase): 37 | """Basic model manager method tests.""" 38 | 39 | def test_live_rules(self): 40 | _ = RuleSet.objects.create(uri_regex="", enabled=True) 41 | self.assertEqual(RuleSet.objects.live_rules().count(), 1) 42 | 43 | r2 = RuleSet.objects.create(uri_regex="", enabled=True) 44 | self.assertEqual(RuleSet.objects.count(), 2) 45 | self.assertEqual(RuleSet.objects.live_rules().count(), 2) 46 | 47 | r2.enabled = False 48 | r2.save() 49 | self.assertEqual(RuleSet.objects.live_rules().count(), 1) 50 | 51 | def test_live_rules_with_caching(self): 52 | settings.RULESET_CACHE_TIMEOUT = 10 53 | self.assertIsNone(cache.get(settings.RULESET_CACHE_KEY)) 54 | # save a couple of rules 55 | RuleSet.objects.create(uri_regex="", enabled=True) 56 | RuleSet.objects.create(uri_regex="", enabled=True) 57 | self.assertEqual(RuleSet.objects.live_rules().count(), 2) 58 | self.assertIsNotNone(cache.get(settings.RULESET_CACHE_KEY)) 59 | # cache is full, delete the underlying records and retrieve 60 | RuleSet.objects.all().delete() 61 | # we're going to the cache, so even so DB is empty, we get two back 62 | self.assertEqual(RuleSet.objects.live_rules().count(), 2) 63 | # clear out cache and confirm we're now going direct to DB 64 | cache.clear() 65 | self.assertEqual(RuleSet.objects.live_rules().count(), 0) 66 | 67 | 68 | class RuleSetModelTests(TestCase): 69 | """Basic model properrty and method tests.""" 70 | 71 | def setUp(self): 72 | pass 73 | 74 | def test_default_properties(self): 75 | ruleset = RuleSet() 76 | props = [ 77 | ("enabled", True), 78 | ("uri_regex", ""), 79 | ("user_filter_type", 0), 80 | ("user_group_filter", ""), 81 | ] 82 | for p in props: 83 | self.assertEqual(getattr(ruleset, p[0]), p[1]) 84 | 85 | def test_has_group_filter(self): 86 | ruleset = RuleSet() 87 | filters = (("", False), (" ", False), ("test", True)) 88 | for f in filters: 89 | ruleset.user_group_filter = f[0] 90 | self.assertEqual(ruleset.has_group_filter, f[1]) 91 | 92 | def test_clean(self): 93 | ruleset = RuleSet(user_group_filter="test") 94 | for f in (RuleSet.USER_FILTER_ALL, RuleSet.USER_FILTER_AUTH): 95 | ruleset.user_filter_type = f 96 | self.assertRaises(ValidationError, ruleset.clean) 97 | ruleset.user_filter_type = RuleSet.USER_FILTER_GROUP 98 | ruleset.clean() 99 | # now try the opposite - user_filter_type set, but no group set 100 | ruleset.user_group_filter = "" 101 | self.assertRaises(ValidationError, ruleset.clean) 102 | 103 | def test_clean_bad_regex(self): 104 | # try with a bad regex 105 | ruleset = RuleSet() 106 | ruleset.uri_regex = "*" 107 | self.assertRaises(ValidationError, ruleset.clean) 108 | 109 | def test_match_uri(self): 110 | ruleset = RuleSet("") 111 | uri = "/test/" 112 | regexes = ( 113 | ("", True), 114 | (" ", True), 115 | ("^/test", True), 116 | (".", True), 117 | ("/x", False), 118 | ("*", False), # bad regex - will fail 119 | ) 120 | 121 | for r in regexes: 122 | ruleset.uri_regex = r[0] 123 | self.assertEqual(ruleset.match_uri(uri), r[1]) 124 | 125 | @skipIfCustomUser 126 | def test_match_user(self): 127 | ruleset = RuleSet("") 128 | self.assertFalse(ruleset.has_group_filter) 129 | self.assertEqual(ruleset.user_filter_type, RuleSet.USER_FILTER_ALL) 130 | 131 | # start with no user / anonymous 132 | self.assertTrue(ruleset.match_user(None)) 133 | self.assertTrue(ruleset.match_user(AnonymousUser())) 134 | 135 | # now exclude anonymous 136 | ruleset.user_filter_type = RuleSet.USER_FILTER_AUTH 137 | self.assertFalse(ruleset.match_user(None)) 138 | self.assertFalse(ruleset.match_user(AnonymousUser())) 139 | 140 | # create a real user, but still no group filter 141 | bob = User.objects.create_user("Bob") 142 | self.assertFalse(bob.groups.exists()) 143 | self.assertFalse(bob.is_staff) 144 | self.assertTrue(bob.is_authenticated) 145 | self.assertTrue(ruleset.match_user(bob)) 146 | 147 | # now create the filter, and check bob no longer matches 148 | ruleset.user_filter_type = RuleSet.USER_FILTER_GROUP 149 | ruleset.user_group_filter = "test" 150 | test_group = Group(name="test") 151 | test_group.save() 152 | self.assertFalse(ruleset.match_user(bob)) 153 | 154 | # add bob to the group, and check he now matches 155 | bob.groups.add(test_group) 156 | self.assertTrue(bob.groups.filter(name="test").exists()) 157 | self.assertTrue(ruleset.match_user(bob)) 158 | 159 | # test setting an invalid value 160 | ruleset.user_filter_type = -1 161 | self.assertFalse(ruleset.match_user(bob)) 162 | bob.is_staff = False 163 | self.assertFalse(ruleset.match_user(bob)) 164 | self.assertFalse(ruleset.match_user(None)) 165 | self.assertFalse(ruleset.match_user(AnonymousUser())) 166 | 167 | @skipIfDefaultUser 168 | def test_match_custom_user(self): 169 | ruleset = RuleSet("") 170 | self.assertFalse(ruleset.has_group_filter) 171 | self.assertEqual(ruleset.user_filter_type, RuleSet.USER_FILTER_ALL) 172 | 173 | # start with no user / anonymous 174 | self.assertTrue(ruleset.match_user(None)) 175 | self.assertTrue(ruleset.match_user(AnonymousUser())) 176 | 177 | # now exclude anonymous 178 | ruleset.user_filter_type = RuleSet.USER_FILTER_AUTH 179 | self.assertFalse(ruleset.match_user(None)) 180 | self.assertFalse(ruleset.match_user(AnonymousUser())) 181 | 182 | # create a real user, but still no group filter 183 | bob = CustomUser.objects.create_user( 184 | mobile_number="+886-999888777", password="pass11" 185 | ) 186 | self.assertFalse(bob.groups.exists()) 187 | self.assertFalse(bob.is_staff) 188 | self.assertTrue(bob.is_authenticated) 189 | self.assertTrue(ruleset.match_user(bob)) 190 | 191 | # now create the filter, and check bob no longer matches 192 | ruleset.user_filter_type = RuleSet.USER_FILTER_GROUP 193 | ruleset.user_group_filter = "test" 194 | test_group = Group(name="test") 195 | test_group.save() 196 | self.assertFalse(ruleset.match_user(bob)) 197 | 198 | # add bob to the group, and check he now matches 199 | bob.groups.add(test_group) 200 | self.assertTrue(bob.groups.filter(name="test").exists()) 201 | self.assertTrue(ruleset.match_user(bob)) 202 | 203 | # test setting an invalid value 204 | ruleset.user_filter_type = -1 205 | self.assertFalse(ruleset.match_user(bob)) 206 | bob.is_staff = False 207 | self.assertFalse(ruleset.match_user(bob)) 208 | self.assertFalse(ruleset.match_user(None)) 209 | self.assertFalse(ruleset.match_user(AnonymousUser())) 210 | 211 | 212 | class ProfilingRecordModelTests(TestCase): 213 | """Basic model properrty and method tests.""" 214 | 215 | def setUp(self): 216 | cache.clear() 217 | 218 | def test_default_properties(self): 219 | profile = ProfilingRecord() 220 | props = [ 221 | ("user", None), 222 | ("session_key", ""), 223 | ("start_ts", None), 224 | ("end_ts", None), 225 | ("duration", None), 226 | ("http_method", ""), 227 | ("request_uri", ""), 228 | ("remote_addr", ""), 229 | ("http_user_agent", ""), 230 | ("http_referer", ""), 231 | ("view_func_name", ""), 232 | ("response_status_code", None), 233 | ] 234 | for p in props: 235 | self.assertEqual(getattr(profile, p[0]), p[1]) 236 | self.assertIsNotNone(str(profile)) 237 | self.assertIsNotNone(repr(profile)) 238 | 239 | def test_start(self): 240 | profile = ProfilingRecord() 241 | self.assertFalse(profile.is_running) 242 | profile.start() 243 | self.assertIsNotNone(profile.start_ts) 244 | self.assertIsNone(profile.end_ts) 245 | self.assertIsNone(profile.duration) 246 | self.assertTrue(profile.is_running) 247 | # now check again to see that end and duration are cleared 248 | profile.end_ts = datetime.datetime.utcnow() 249 | profile.duration = 1 250 | profile.start() 251 | self.assertIsNotNone(profile.start_ts) 252 | self.assertIsNone(profile.end_ts) 253 | self.assertIsNone(profile.duration) 254 | self.assertTrue(profile.is_running) 255 | 256 | def test_start__force_debug__FALSE(self): 257 | """Test the FORCE_DEBUG_CURSOR setting.""" 258 | settings.FORCE_DEBUG_CURSOR = False 259 | profiler = ProfilingRecord().start() 260 | User.objects.exists() 261 | profiler.stop() 262 | self.assertEqual(profiler.query_count, 0) 263 | 264 | def test_start__force_debug__TRUE(self): 265 | settings.FORCE_DEBUG_CURSOR = True 266 | profiler = ProfilingRecord().start() 267 | User.objects.exists() 268 | profiler.stop() 269 | self.assertEqual(profiler.query_count, 1) 270 | self.assertFalse(connection.force_debug_cursor) 271 | 272 | def test_stop(self): 273 | profile = ProfilingRecord() 274 | self.assertRaises(ValueError, profile.stop) 275 | profile.start().stop() 276 | self.assertIsNotNone(profile.start_ts) 277 | self.assertIsNotNone(profile.end_ts) 278 | self.assertIsNotNone(profile.duration) 279 | self.assertTrue(profile.duration > 0) 280 | self.assertFalse(profile.is_running) 281 | 282 | def test_cancel(self): 283 | profile = ProfilingRecord().cancel() 284 | self.assertIsNone(profile.start_ts) 285 | self.assertIsNone(profile.end_ts) 286 | self.assertIsNone(profile.duration) 287 | self.assertFalse(profile.is_running) 288 | # same thing, but this time post-start 289 | profile = ProfilingRecord().start().cancel() 290 | self.assertIsNone(profile.start_ts) 291 | self.assertIsNone(profile.end_ts) 292 | self.assertIsNone(profile.duration) 293 | self.assertFalse(profile.is_running) 294 | 295 | def test_capture(self): 296 | # repeat, but this time cancel before capture 297 | profile = ProfilingRecord() 298 | response = MockResponse(200) 299 | profile.start() 300 | profile.process_response(response) 301 | profile.capture() 302 | self.assertIsNotNone(profile.start_ts) 303 | self.assertIsNotNone(profile.end_ts) 304 | self.assertIsNotNone(profile.duration) 305 | self.assertIsNotNone(profile.id) 306 | self.assertFalse(profile.is_running) 307 | self.assertEqual(response["X-Profiler-Duration"], profile.duration) 308 | 309 | def test_capture__BadProfileError(self): 310 | profile = ProfilingRecord() 311 | profile.start() 312 | profile.cancel() 313 | self.assertRaises(BadProfilerError, profile.capture) 314 | 315 | def test_elapsed(self): 316 | profile = ProfilingRecord() 317 | with self.assertRaises(ValueError): 318 | profile.elapsed 319 | profile.start() 320 | self.assertIsNotNone(profile.elapsed) 321 | self.assertIsNone(profile.end_ts) 322 | self.assertIsNone(profile.duration) 323 | 324 | @skipIfCustomUser 325 | def test_process_request(self): 326 | factory = RequestFactory() 327 | request = factory.get("/test") 328 | request.META["HTTP_USER_AGENT"] = "test-browser" 329 | request.META["HTTP_REFERER"] = "google.com" 330 | profile = ProfilingRecord() 331 | 332 | profile.process_request(request) 333 | self.assertEqual(profile.request, request) 334 | self.assertEqual(profile.http_method, request.method) 335 | self.assertEqual(profile.request_uri, request.path) 336 | # for some reason user-agent is a tuple - need to read specs! 337 | self.assertEqual(profile.http_user_agent, "test-browser") 338 | self.assertEqual(profile.http_referer, "google.com") 339 | self.assertEqual(profile.session_key, "") 340 | self.assertEqual(profile.user, None) 341 | 342 | # test that we can set the session 343 | request.session = MockSession("test-session-key") 344 | profile = ProfilingRecord() 345 | profile.process_request(request) 346 | self.assertEqual(profile.session_key, "test-session-key") 347 | 348 | # test that we can set the user 349 | request.user = User.objects.create_user("bob") 350 | profile = ProfilingRecord() 351 | profile.process_request(request) 352 | self.assertEqual(profile.user, request.user) 353 | 354 | # but we do not save anonymous users 355 | request.user = AnonymousUser() 356 | profile = ProfilingRecord() 357 | profile.process_request(request) 358 | self.assertEqual(profile.user, None) 359 | 360 | @skipIfDefaultUser 361 | def test_process_request_with_custom_user(self): 362 | factory = RequestFactory() 363 | request = factory.get("/test") 364 | request.META["HTTP_USER_AGENT"] = "test-browser" 365 | request.META["HTTP_REFERER"] = "google.com" 366 | profile = ProfilingRecord() 367 | 368 | profile.process_request(request) 369 | self.assertEqual(profile.request, request) 370 | self.assertEqual(profile.http_method, request.method) 371 | self.assertEqual(profile.request_uri, request.path) 372 | # for some reason user-agent is a tuple - need to read specs! 373 | self.assertEqual(profile.http_user_agent, "test-browser") 374 | self.assertEqual(profile.http_referer, "google.com") 375 | self.assertEqual(profile.session_key, "") 376 | self.assertEqual(profile.user, None) 377 | 378 | # test that we can set the session 379 | request.session = MockSession("test-session-key") 380 | profile = ProfilingRecord() 381 | profile.process_request(request) 382 | self.assertEqual(profile.session_key, "test-session-key") 383 | 384 | # test that we can set the custom user 385 | request.user = CustomUser.objects.create_user( 386 | mobile_number="+886-999888777", password="pass11" 387 | ) 388 | profile = ProfilingRecord() 389 | profile.process_request(request) 390 | self.assertEqual(profile.user, request.user) 391 | 392 | # but we do not save anonymous users 393 | request.user = AnonymousUser() 394 | profile = ProfilingRecord() 395 | profile.process_request(request) 396 | self.assertEqual(profile.user, None) 397 | 398 | def test_process_response(self): 399 | response = MockResponse(200) 400 | profiler = ProfilingRecord().start() 401 | profiler.process_response(response) 402 | self.assertEqual(profiler.response, response) 403 | self.assertEqual(profiler.response_status_code, 200) 404 | self.assertEqual(profiler.response_content_length, 13) 405 | 406 | def test__stream_response_content_length(self): 407 | response = StreamingHttpResponse("Hello, World!") 408 | profiler = ProfilingRecord().start() 409 | profiler.process_response(response) 410 | self.assertEqual(profiler.response, response) 411 | self.assertEqual(profiler.response_status_code, 200) 412 | self.assertEqual(profiler.response_content_length, -1) 413 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from request_profiler import settings 5 | from request_profiler.models import ProfilingRecord, RuleSet 6 | 7 | 8 | class ViewTests(TestCase): 9 | def setUp(self): 10 | # set up one, catch-all rule. 11 | # RuleSet.objects.all().delete() 12 | self.rule = RuleSet.objects.create(enabled=True) 13 | 14 | def test_rules_match_response(self): 15 | url = reverse("test_response") 16 | response = self.client.get(url) 17 | self.assertTrue(response.has_header("X-Profiler-Duration")) 18 | record = ProfilingRecord.objects.get() 19 | self.assertIsNone(record.user) 20 | # session is save even if user is Anonymous 21 | self.assertNotEqual(record.session_key, "") 22 | self.assertEqual(record.http_user_agent, "") 23 | self.assertEqual(record.http_referer, "") 24 | self.assertEqual(record.http_method, "GET") 25 | self.assertEqual(record.view_func_name, "test_response") 26 | self.assertEqual(str(record.duration), response["X-Profiler-Duration"]) 27 | self.assertEqual(record.response_status_code, 200) 28 | 29 | def test_rules_match_view(self): 30 | url = reverse("test_view") 31 | response = self.client.get(url) 32 | self.assertTrue(response.has_header("X-Profiler-Duration")) 33 | record = ProfilingRecord.objects.get() 34 | self.assertIsNone(record.user) 35 | self.assertNotEqual(record.session_key, "") 36 | self.assertEqual(record.http_user_agent, "") 37 | self.assertEqual(record.http_referer, "") 38 | self.assertEqual(record.http_method, "GET") 39 | self.assertEqual(record.view_func_name, "test_view") 40 | self.assertEqual(str(record.duration), response["X-Profiler-Duration"]) 41 | self.assertEqual(record.response_status_code, 200) 42 | 43 | def test_rules_match_cbv_view(self): 44 | url = reverse("test_cbv") 45 | response = self.client.get(url) 46 | self.assertTrue(response.has_header("X-Profiler-Duration")) 47 | record = ProfilingRecord.objects.get() 48 | self.assertIsNone(record.user) 49 | self.assertNotEqual(record.session_key, "") 50 | self.assertEqual(record.http_user_agent, "") 51 | self.assertEqual(record.http_referer, "") 52 | self.assertEqual(record.http_method, "GET") 53 | self.assertEqual(record.view_func_name, "TestView") 54 | self.assertEqual(str(record.duration), response["X-Profiler-Duration"]) 55 | self.assertEqual(record.response_status_code, 200) 56 | 57 | def test_rules_match_callable_view(self): 58 | url = reverse("test_callable_view") 59 | response = self.client.get(url) 60 | self.assertTrue(response.has_header("X-Profiler-Duration")) 61 | record = ProfilingRecord.objects.get() 62 | self.assertIsNone(record.user) 63 | self.assertNotEqual(record.session_key, "") 64 | self.assertEqual(record.http_user_agent, "") 65 | self.assertEqual(record.http_referer, "") 66 | self.assertEqual(record.http_method, "GET") 67 | self.assertEqual(record.view_func_name, "CallableTestView") 68 | self.assertEqual(str(record.duration), response["X-Profiler-Duration"]) 69 | self.assertEqual(record.response_status_code, 200) 70 | 71 | def test_rules_match_view_no_session(self): 72 | url = reverse("test_view") 73 | settings.STORE_ANONYMOUS_SESSIONS = False 74 | response = self.client.get(url) 75 | self.assertTrue(response.has_header("X-Profiler-Duration")) 76 | record = ProfilingRecord.objects.get() 77 | self.assertIsNone(record.user) 78 | self.assertEqual(record.session_key, "") 79 | 80 | def test_404(self): 81 | # Validate that the profiler handles an error page 82 | url = reverse("test_404") 83 | response = self.client.get(url) 84 | self.assertTrue(response.has_header("X-Profiler-Duration")) 85 | self.assertEqual(ProfilingRecord.objects.get().response_status_code, 404) 86 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | admin.autodiscover() 7 | 8 | urlpatterns = [ 9 | path("admin/", admin.site.urls), 10 | path("test/response/", views.test_response, name="test_response"), 11 | path("test/view/", views.test_view, name="test_view"), 12 | path("test/404/", views.test_404, name="test_404"), 13 | path("test/class-based-view/", views.TestView.as_view(), name="test_cbv"), 14 | path("test/callable-view/", views.CallableTestView(), name="test_callable_view"), 15 | ] 16 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from unittest import skipIf 2 | 3 | from django.conf import settings 4 | 5 | 6 | def skipIfDefaultUser(test_func): 7 | """Skip a test if a default user model is in use.""" 8 | return skipIf(settings.AUTH_USER_MODEL == "auth.User", "Default user model in use")( 9 | test_func 10 | ) 11 | 12 | 13 | def skipIfCustomUser(test_func): 14 | """Skip a test if a custom user model is in use.""" 15 | return skipIf(settings.AUTH_USER_MODEL != "auth.User", "Custom user model in use")( 16 | test_func 17 | ) 18 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404, HttpResponse 2 | from django.shortcuts import render 3 | from django.views import View 4 | 5 | 6 | def test_response(request): 7 | return HttpResponse("this is a test") 8 | 9 | 10 | def test_view(request): 11 | return render(request, "test.html") 12 | 13 | 14 | def test_404(request): 15 | raise Http404() 16 | 17 | 18 | class TestView(View): 19 | def get(self, request): 20 | return HttpResponse("this is a response of CBV") 21 | 22 | 23 | class CallableTestView(object): 24 | def __init__(self, response_text="this is a test"): 25 | self._response_text = response_text 26 | 27 | def __call__(self, request): 28 | return HttpResponse(self._response_text) 29 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | fmt, lint, mypy, 5 | django-checks, 6 | ; https://docs.djangoproject.com/en/5.0/releases/ 7 | django32-py{39,310} 8 | django40-py{39,310} 9 | django41-py{39,310,311} 10 | django42-py{39,310,311} 11 | django50-py{310,311,312} 12 | djangomain-py{311,312} 13 | 14 | [testenv] 15 | deps = 16 | coverage 17 | pytest 18 | pytest-cov 19 | pytest-django 20 | django32: Django>=3.2,<3.3 21 | django40: Django>=4.0,<4.1 22 | django41: Django>=4.1,<4.2 23 | django42: Django>=4.2,<4.3 24 | django50: https://github.com/django/django/archive/stable/5.0.x.tar.gz 25 | djangomain: https://github.com/django/django/archive/main.tar.gz 26 | 27 | commands = 28 | pytest --cov=request_profiler --verbose tests/ 29 | 30 | [testenv:django-checks] 31 | description = Django system checks and missing migrations 32 | deps = Django 33 | commands = 34 | python manage.py check --fail-level WARNING 35 | python manage.py makemigrations --dry-run --check --verbosity 3 36 | 37 | [testenv:fmt] 38 | description = Python source code formatting (black) 39 | deps = 40 | black 41 | 42 | commands = 43 | black --check request_profiler 44 | 45 | [testenv:lint] 46 | description = Python source code linting (ruff) 47 | deps = 48 | ruff 49 | 50 | commands = 51 | ruff request_profiler 52 | 53 | [testenv:mypy] 54 | description = Python source code type hints (mypy) 55 | deps = 56 | mypy 57 | 58 | commands = 59 | mypy request_profiler 60 | --------------------------------------------------------------------------------