├── .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 |
--------------------------------------------------------------------------------