django-statsd 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_statsd/__init__.py +32 -0
- django_statsd/celery.py +54 -0
- django_statsd/database.py +33 -0
- django_statsd/json.py +21 -0
- django_statsd/middleware.py +581 -0
- django_statsd/py.typed +0 -0
- django_statsd/redis.py +47 -0
- django_statsd/settings.py +62 -0
- django_statsd/templates.py +15 -0
- django_statsd/utils.py +51 -0
- django_statsd-3.0.0.dist-info/METADATA +243 -0
- django_statsd-3.0.0.dist-info/RECORD +14 -0
- django_statsd-3.0.0.dist-info/WHEEL +4 -0
- django_statsd-3.0.0.dist-info/licenses/LICENSE +30 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""django-statsd: submit Django query and view durations to statsd."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
from django_statsd import celery, json, redis, templates
|
|
6
|
+
from django_statsd.middleware import (
|
|
7
|
+
decorator,
|
|
8
|
+
decr,
|
|
9
|
+
incr,
|
|
10
|
+
named_wrapper,
|
|
11
|
+
start,
|
|
12
|
+
stop,
|
|
13
|
+
with_,
|
|
14
|
+
wrapper,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__: str = metadata.version('django-statsd')
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
'celery',
|
|
21
|
+
'decorator',
|
|
22
|
+
'decr',
|
|
23
|
+
'incr',
|
|
24
|
+
'json',
|
|
25
|
+
'named_wrapper',
|
|
26
|
+
'redis',
|
|
27
|
+
'start',
|
|
28
|
+
'stop',
|
|
29
|
+
'templates',
|
|
30
|
+
'with_',
|
|
31
|
+
'wrapper',
|
|
32
|
+
]
|
django_statsd/celery.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Celery signal integration submitting task counters and timings."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django_statsd import middleware, utils
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from celery.utils import dispatch
|
|
10
|
+
|
|
11
|
+
from celery import signals
|
|
12
|
+
except ImportError: # pragma: no cover
|
|
13
|
+
# celery ships no type information (see [[tool.mypy.overrides]]),
|
|
14
|
+
# so `signals`/`dispatch` are already typed `Any` for mypy and
|
|
15
|
+
# reassigning `None` here needs no ignore there. ty resolves the
|
|
16
|
+
# real `celery.utils.dispatch` module instead, so that one needs
|
|
17
|
+
# its own suppression.
|
|
18
|
+
signals = None
|
|
19
|
+
dispatch = None # ty: ignore[invalid-assignment]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if signals is not None and dispatch is not None:
|
|
23
|
+
counter = utils.get_counter('celery.status')
|
|
24
|
+
|
|
25
|
+
def _make_increment(signal_name: str) -> Callable[..., None]:
|
|
26
|
+
def _increment(**kwargs: Any) -> None:
|
|
27
|
+
counter.increment(signal_name)
|
|
28
|
+
|
|
29
|
+
return _increment
|
|
30
|
+
|
|
31
|
+
for _signal_name in dir(signals):
|
|
32
|
+
_instance = getattr(signals, _signal_name)
|
|
33
|
+
if isinstance(_instance, dispatch.Signal):
|
|
34
|
+
# weak=False: the receiver is a closure that would otherwise
|
|
35
|
+
# be garbage collected immediately and never fire.
|
|
36
|
+
_instance.connect(_make_increment(_signal_name), weak=False)
|
|
37
|
+
|
|
38
|
+
def start(**kwargs: Any) -> None:
|
|
39
|
+
"""Open a scope when a task starts."""
|
|
40
|
+
middleware.StatsdMiddleware.start('celery')
|
|
41
|
+
|
|
42
|
+
def stop(task: Any = None, **kwargs: Any) -> None:
|
|
43
|
+
"""Submit the task's metrics and close the scope."""
|
|
44
|
+
if task is not None:
|
|
45
|
+
middleware.StatsdMiddleware.stop(task.name)
|
|
46
|
+
middleware.StatsdMiddleware.scope.timings = None
|
|
47
|
+
|
|
48
|
+
def clear(**kwargs: Any) -> None:
|
|
49
|
+
"""Drop the scope, so a failed task leaves no timer running."""
|
|
50
|
+
middleware.StatsdMiddleware.scope.timings = None
|
|
51
|
+
|
|
52
|
+
signals.task_prerun.connect(start)
|
|
53
|
+
signals.task_postrun.connect(stop)
|
|
54
|
+
signals.task_failure.connect(clear)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Database query timing through Django's ``execute_wrapper`` hooks.
|
|
2
|
+
|
|
3
|
+
Enabled by the ``STATSD_TRACK_DATABASE`` setting;
|
|
4
|
+
:class:`~django_statsd.middleware.StatsdMiddleware` wraps every configured
|
|
5
|
+
connection for the duration of each request and submits the query
|
|
6
|
+
durations as ``sql.<alias>`` timings.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
QueryContext = dict[str, Any]
|
|
13
|
+
ExecuteFunc = Callable[[str, Any, bool, QueryContext], Any]
|
|
14
|
+
ExecuteWrapper = Callable[[ExecuteFunc, str, Any, bool, QueryContext], Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def statsd_execute_wrapper(alias: str) -> ExecuteWrapper:
|
|
18
|
+
"""Build an execute wrapper timing queries as ``sql.<alias>``."""
|
|
19
|
+
# Imported here to avoid a circular import: middleware imports this
|
|
20
|
+
# module when installing the wrappers.
|
|
21
|
+
from django_statsd import middleware
|
|
22
|
+
|
|
23
|
+
def timed_execute(
|
|
24
|
+
execute: ExecuteFunc,
|
|
25
|
+
sql: str,
|
|
26
|
+
params: Any,
|
|
27
|
+
many: bool,
|
|
28
|
+
context: QueryContext,
|
|
29
|
+
) -> Any:
|
|
30
|
+
with middleware.with_(f'sql.{alias}'):
|
|
31
|
+
return execute(sql, params, many, context)
|
|
32
|
+
|
|
33
|
+
return timed_execute
|
django_statsd/json.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# The module is intentionally named `json` to mirror the patched
|
|
2
|
+
# library (like `celery.py`/`redis.py`/`templates.py`); it is public
|
|
3
|
+
# API (`django_statsd.json`, see `__init__.__all__` and
|
|
4
|
+
# `docs/django_statsd.rst`), so renaming it would be a breaking change.
|
|
5
|
+
# ruff: noqa: A005
|
|
6
|
+
"""Time stdlib :mod:`json` calls as ``json.<function>`` metrics."""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from django_statsd import middleware
|
|
11
|
+
|
|
12
|
+
if not hasattr(json, 'statsd_patched'):
|
|
13
|
+
# Monkeypatching the stdlib json module: none of the checkers know
|
|
14
|
+
# about `statsd_patched`, and ty additionally resolves `json.load`
|
|
15
|
+
# et al. to their concrete stdlib signatures, so a wrapped callable
|
|
16
|
+
# is not assignable there either.
|
|
17
|
+
json.statsd_patched = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
|
|
18
|
+
json.load = middleware.wrapper('json', json.load) # ty: ignore[invalid-assignment]
|
|
19
|
+
json.loads = middleware.wrapper('json', json.loads) # ty: ignore[invalid-assignment]
|
|
20
|
+
json.dump = middleware.wrapper('json', json.dump) # ty: ignore[invalid-assignment]
|
|
21
|
+
json.dumps = middleware.wrapper('json', json.dumps) # ty: ignore[invalid-assignment]
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Statsd middleware tracking view, middleware and database timings."""
|
|
2
|
+
|
|
3
|
+
import collections
|
|
4
|
+
import functools
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from contextlib import ExitStack
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, ClassVar, ParamSpec, TypeVar
|
|
13
|
+
|
|
14
|
+
import statsd
|
|
15
|
+
from asgiref.local import Local
|
|
16
|
+
from django.db import connections
|
|
17
|
+
from django.http import HttpRequest
|
|
18
|
+
from django.http.response import HttpResponseBase
|
|
19
|
+
|
|
20
|
+
from django_statsd import settings, utils
|
|
21
|
+
|
|
22
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
P = ParamSpec('P')
|
|
25
|
+
T = TypeVar('T')
|
|
26
|
+
|
|
27
|
+
GetResponse = Callable[[HttpRequest], HttpResponseBase]
|
|
28
|
+
|
|
29
|
+
TAGS_LIKE_SUPPORTED: tuple[str, ...] = ('=', '_is_')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_tags_like() -> str | None:
|
|
33
|
+
"""Validate ``STATSD_TAGS_LIKE`` into a separator or ``None``."""
|
|
34
|
+
tags_like = settings.STATSD_TAGS_LIKE
|
|
35
|
+
if tags_like is None:
|
|
36
|
+
return None
|
|
37
|
+
if tags_like is True:
|
|
38
|
+
return '_is_'
|
|
39
|
+
if tags_like in TAGS_LIKE_SUPPORTED:
|
|
40
|
+
return str(tags_like)
|
|
41
|
+
|
|
42
|
+
warnings.warn(
|
|
43
|
+
'Unsupported `STATSD_TAGS_LIKE` setting. '
|
|
44
|
+
f'Please, choose from {TAGS_LIKE_SUPPORTED!r}',
|
|
45
|
+
stacklevel=2,
|
|
46
|
+
)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
MAKE_TAGS_LIKE: str | None = _get_tags_like()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_ajax(request: HttpRequest) -> bool:
|
|
54
|
+
"""Recreate the old Django ``is_ajax`` check (jQuery-style ajax)."""
|
|
55
|
+
return request.headers.get('x-requested-with') == 'XMLHttpRequest'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class WithTimer:
|
|
59
|
+
"""Context manager returned by calling a :class:`Timer`."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, timer: 'Timer', key: str) -> None:
|
|
62
|
+
"""Bind the timer and the key this block will be recorded under."""
|
|
63
|
+
self.timer = timer
|
|
64
|
+
self.key = key
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> None:
|
|
67
|
+
"""Start the timer."""
|
|
68
|
+
self.timer.start(self.key)
|
|
69
|
+
|
|
70
|
+
def __exit__(
|
|
71
|
+
self,
|
|
72
|
+
type_: type[BaseException] | None,
|
|
73
|
+
value: BaseException | None,
|
|
74
|
+
traceback: TracebackType | None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Stop the timer, however the block was left."""
|
|
77
|
+
self.timer.stop(self.key)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Client:
|
|
81
|
+
"""Base for the scope's metric holders.
|
|
82
|
+
|
|
83
|
+
Collects values during a request and submits them in one go, so a
|
|
84
|
+
request produces one burst of packets instead of a trickle.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
class_: ClassVar[type[Any]] = statsd.Client
|
|
88
|
+
|
|
89
|
+
def __init__(self, prefix: str = 'view') -> None:
|
|
90
|
+
"""Build the prefix, nesting it under ``STATSD_PREFIX``."""
|
|
91
|
+
if settings.STATSD_PREFIX:
|
|
92
|
+
prefix = f'{settings.STATSD_PREFIX}.{prefix}'
|
|
93
|
+
self.prefix: str = prefix
|
|
94
|
+
|
|
95
|
+
def get_client(self, *args: str | None) -> Any:
|
|
96
|
+
"""Build a python-statsd client for this prefix plus `args`."""
|
|
97
|
+
prefix = '.'.join(a for a in (self.prefix, *args) if a)
|
|
98
|
+
return utils.get_client(prefix, class_=self.class_)
|
|
99
|
+
|
|
100
|
+
def submit(self, *args: str | None) -> None:
|
|
101
|
+
"""Send everything collected. Subclasses define what that means.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
NotImplementedError: always, on the base class.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
raise NotImplementedError('Subclasses must define a `submit` function')
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Counter(Client):
|
|
111
|
+
"""Counters accumulated over one request or task."""
|
|
112
|
+
|
|
113
|
+
class_: ClassVar[type[Any]] = statsd.Counter
|
|
114
|
+
|
|
115
|
+
def __init__(self, prefix: str = 'view') -> None:
|
|
116
|
+
"""Start with every counter at zero."""
|
|
117
|
+
super().__init__(prefix)
|
|
118
|
+
self.data: collections.defaultdict[str, int] = collections.defaultdict(
|
|
119
|
+
int
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def increment(self, key: str, delta: int = 1) -> None:
|
|
123
|
+
"""Add `delta` to `key`."""
|
|
124
|
+
self.data[key] += delta
|
|
125
|
+
|
|
126
|
+
def decrement(self, key: str, delta: int = 1) -> None:
|
|
127
|
+
"""Subtract `delta` from `key`."""
|
|
128
|
+
self.data[key] -= delta
|
|
129
|
+
|
|
130
|
+
def submit(self, *args: str | None) -> None:
|
|
131
|
+
"""Send every counter that moved. Zeroes are not worth a packet."""
|
|
132
|
+
client = self.get_client(*args)
|
|
133
|
+
for key, value in self.data.items():
|
|
134
|
+
if value:
|
|
135
|
+
client.increment(key, value)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Timer(Client):
|
|
139
|
+
"""Timings accumulated over one request or task.
|
|
140
|
+
|
|
141
|
+
A key may be started more than once before it is stopped, so the
|
|
142
|
+
starts are kept on a stack and the durations add up.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
class_: ClassVar[type[Any]] = statsd.Timer
|
|
146
|
+
|
|
147
|
+
def __init__(self, prefix: str = 'view') -> None:
|
|
148
|
+
"""Start with no timers running and nothing recorded."""
|
|
149
|
+
super().__init__(prefix)
|
|
150
|
+
self.starts: collections.defaultdict[str, collections.deque[float]] = (
|
|
151
|
+
collections.defaultdict(collections.deque)
|
|
152
|
+
)
|
|
153
|
+
self.data: collections.defaultdict[str, float] = (
|
|
154
|
+
collections.defaultdict(float)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def start(self, key: str) -> None:
|
|
158
|
+
"""Start timing `key`."""
|
|
159
|
+
self.starts[key].append(time.time())
|
|
160
|
+
|
|
161
|
+
def stop(self, key: str) -> float:
|
|
162
|
+
"""Stop timing `key` and add the elapsed time to its total.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
key: The name passed to a matching :meth:`start`.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The seconds that elapsed since that `start`.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
AssertionError: if `key` was never started.
|
|
172
|
+
|
|
173
|
+
"""
|
|
174
|
+
assert self.starts[key], (
|
|
175
|
+
f'Unable to stop tracking {key}, never started tracking it'
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
delta = time.time() - self.starts[key].pop()
|
|
179
|
+
# Clean up when we're done
|
|
180
|
+
if not self.starts[key]:
|
|
181
|
+
del self.starts[key]
|
|
182
|
+
|
|
183
|
+
self.data[key] += delta
|
|
184
|
+
return delta
|
|
185
|
+
|
|
186
|
+
def submit(self, *args: str | None) -> None:
|
|
187
|
+
"""Send every recorded timing and clear them.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
AssertionError: under ``STATSD_DEBUG``, if a timer was
|
|
191
|
+
started and never stopped.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
client = self.get_client(*args)
|
|
195
|
+
for key in list(self.data.keys()):
|
|
196
|
+
client.send(key, self.data.pop(key))
|
|
197
|
+
|
|
198
|
+
if settings.STATSD_DEBUG:
|
|
199
|
+
assert not self.starts, (
|
|
200
|
+
f'Timer(s) {dict(self.starts)!r} were started but '
|
|
201
|
+
'never stopped'
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def __call__(self, key: str) -> WithTimer:
|
|
205
|
+
"""Return a context manager timing `key`."""
|
|
206
|
+
return WithTimer(self, key)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class StatsdMiddleware:
|
|
210
|
+
"""Opens the scope and submits the per-view metrics.
|
|
211
|
+
|
|
212
|
+
Goes at the top of ``MIDDLEWARE``, with
|
|
213
|
+
:class:`StatsdMiddlewareTimer` at the bottom. The scope lives on
|
|
214
|
+
:class:`asgiref.local.Local`, so each request gets its own under
|
|
215
|
+
both WSGI and ASGI.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
scope: ClassVar[Local] = Local()
|
|
219
|
+
|
|
220
|
+
def __init__(self, get_response: GetResponse) -> None:
|
|
221
|
+
"""Store the next callable in the middleware chain."""
|
|
222
|
+
self.get_response = get_response
|
|
223
|
+
|
|
224
|
+
def __call__(self, request: HttpRequest) -> HttpResponseBase:
|
|
225
|
+
"""Time one request, wrapping the database if asked to."""
|
|
226
|
+
# Store the timings in the request so it can be used everywhere
|
|
227
|
+
self.process_request(request)
|
|
228
|
+
try:
|
|
229
|
+
with ExitStack() as stack:
|
|
230
|
+
if settings.STATSD_TRACK_DATABASE:
|
|
231
|
+
# Imported here to avoid a circular import at load
|
|
232
|
+
# time: database.py needs this module's helpers.
|
|
233
|
+
from django_statsd import database
|
|
234
|
+
|
|
235
|
+
for alias in connections:
|
|
236
|
+
stack.enter_context(
|
|
237
|
+
connections[alias].execute_wrapper(
|
|
238
|
+
database.statsd_execute_wrapper(alias)
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
response = self.get_response(request)
|
|
242
|
+
return self.process_response(request, response)
|
|
243
|
+
finally:
|
|
244
|
+
self.cleanup(request)
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def _scope_get(cls, name: str) -> Any:
|
|
248
|
+
return getattr(cls.scope, name, None)
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def skip_view(cls, view_name: str) -> bool:
|
|
252
|
+
"""Whether `view_name` matches ``STATSD_VIEWS_TO_SKIP``."""
|
|
253
|
+
for pattern in settings.STATSD_VIEWS_TO_SKIP:
|
|
254
|
+
if re.match(pattern, view_name):
|
|
255
|
+
logger.debug('Skipping metric `%s`', view_name)
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def start(cls, prefix: str = 'view') -> Local:
|
|
262
|
+
"""Open a scope and start the total timer.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
prefix: The metric prefix, ``view`` for requests and
|
|
266
|
+
``celery`` for tasks.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
The scope, which the middleware puts on ``request.statsd``.
|
|
270
|
+
|
|
271
|
+
"""
|
|
272
|
+
cls.scope.timings = Timer(prefix)
|
|
273
|
+
cls.scope.timings.start('total')
|
|
274
|
+
cls.scope.counter = Counter(prefix)
|
|
275
|
+
cls.scope.counter.increment('hit')
|
|
276
|
+
cls.scope.counter_codes = Counter(prefix)
|
|
277
|
+
cls.scope.counter_codes.increment('hit')
|
|
278
|
+
cls.scope.counter_site = Counter(prefix)
|
|
279
|
+
cls.scope.counter_site.increment('hit')
|
|
280
|
+
cls.scope.view_name = None
|
|
281
|
+
return cls.scope
|
|
282
|
+
|
|
283
|
+
@classmethod
|
|
284
|
+
def stop(cls, *key: str) -> None:
|
|
285
|
+
"""Stop the total timer and submit everything collected."""
|
|
286
|
+
timings: Timer | None = cls._scope_get('timings')
|
|
287
|
+
if not timings:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
timings.stop('total')
|
|
291
|
+
timings.submit(*key)
|
|
292
|
+
counter: Counter | None = cls._scope_get('counter')
|
|
293
|
+
if counter:
|
|
294
|
+
counter.submit(*key)
|
|
295
|
+
counter_site: Counter | None = cls._scope_get('counter_site')
|
|
296
|
+
if counter_site:
|
|
297
|
+
counter_site.submit('site')
|
|
298
|
+
|
|
299
|
+
def process_request(self, request: HttpRequest) -> None:
|
|
300
|
+
"""Open the scope and hang it off the request."""
|
|
301
|
+
# request.statsd is a documented dynamic attribute (see
|
|
302
|
+
# docs/django_statsd.rst) set by this middleware; neither
|
|
303
|
+
# django-stubs nor ty know about it.
|
|
304
|
+
request.statsd = self.start() # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
|
|
305
|
+
if settings.STATSD_TRACK_MIDDLEWARE:
|
|
306
|
+
self.scope.timings.start('process_request')
|
|
307
|
+
|
|
308
|
+
def process_view(
|
|
309
|
+
self,
|
|
310
|
+
request: HttpRequest,
|
|
311
|
+
view_func: Callable[..., HttpResponseBase],
|
|
312
|
+
view_args: tuple[Any, ...],
|
|
313
|
+
view_kwargs: dict[str, Any],
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Name the metric after the view Django resolved."""
|
|
316
|
+
timings: Timer | None = self._scope_get('timings')
|
|
317
|
+
if settings.STATSD_TRACK_MIDDLEWARE and timings:
|
|
318
|
+
timings.start('process_view')
|
|
319
|
+
|
|
320
|
+
# View name is defined as module.view
|
|
321
|
+
# (e.g. django.contrib.auth.views.login)
|
|
322
|
+
view_name = view_func.__module__
|
|
323
|
+
|
|
324
|
+
# CBV and callable-instance specific
|
|
325
|
+
if hasattr(view_func, '__name__'):
|
|
326
|
+
view_name = f'{view_name}.{view_func.__name__}'
|
|
327
|
+
else:
|
|
328
|
+
view_name = f'{view_name}.{view_func.__class__.__name__}'
|
|
329
|
+
|
|
330
|
+
if MAKE_TAGS_LIKE:
|
|
331
|
+
view_name = view_name.replace('.', '_')
|
|
332
|
+
view_name = f'view{MAKE_TAGS_LIKE}{view_name}'
|
|
333
|
+
|
|
334
|
+
self.scope.view_name = view_name
|
|
335
|
+
|
|
336
|
+
def process_response(
|
|
337
|
+
self,
|
|
338
|
+
request: HttpRequest,
|
|
339
|
+
response: HttpResponseBase,
|
|
340
|
+
) -> HttpResponseBase:
|
|
341
|
+
"""Count the status class and submit the request's metrics."""
|
|
342
|
+
view_name: str | None = self._scope_get('view_name')
|
|
343
|
+
if view_name and self.skip_view(view_name):
|
|
344
|
+
return response
|
|
345
|
+
|
|
346
|
+
counter_codes: Counter | None = self._scope_get('counter_codes')
|
|
347
|
+
if counter_codes:
|
|
348
|
+
counter_codes.increment(f'{response.status_code // 100}xx')
|
|
349
|
+
counter_codes.submit('http_codes')
|
|
350
|
+
|
|
351
|
+
timings: Timer | None = self._scope_get('timings')
|
|
352
|
+
if settings.STATSD_TRACK_MIDDLEWARE and timings:
|
|
353
|
+
timings.stop('process_response')
|
|
354
|
+
|
|
355
|
+
method = (request.method or 'get').lower()
|
|
356
|
+
if MAKE_TAGS_LIKE:
|
|
357
|
+
tag_method = f'method{MAKE_TAGS_LIKE}{method.replace(".", "_")}'
|
|
358
|
+
ajax = f'is_ajax_{MAKE_TAGS_LIKE}{str(is_ajax(request)).lower()}'
|
|
359
|
+
if view_name:
|
|
360
|
+
self.stop(tag_method, view_name, ajax)
|
|
361
|
+
else:
|
|
362
|
+
if is_ajax(request):
|
|
363
|
+
method += '_ajax'
|
|
364
|
+
if view_name:
|
|
365
|
+
self.stop(method, view_name)
|
|
366
|
+
|
|
367
|
+
self.cleanup(request)
|
|
368
|
+
return response
|
|
369
|
+
|
|
370
|
+
def process_exception(
|
|
371
|
+
self,
|
|
372
|
+
request: HttpRequest,
|
|
373
|
+
exception: BaseException,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Stop the timer and count the failure as a 5xx."""
|
|
376
|
+
timings: Timer | None = self._scope_get('timings')
|
|
377
|
+
if settings.STATSD_TRACK_MIDDLEWARE and timings:
|
|
378
|
+
timings.stop('process_exception')
|
|
379
|
+
|
|
380
|
+
counter_codes: Counter | None = self._scope_get('counter_codes')
|
|
381
|
+
if counter_codes:
|
|
382
|
+
counter_codes.increment('5xx')
|
|
383
|
+
counter_codes.submit('http_codes')
|
|
384
|
+
|
|
385
|
+
def process_template_response(
|
|
386
|
+
self,
|
|
387
|
+
request: HttpRequest,
|
|
388
|
+
response: HttpResponseBase,
|
|
389
|
+
) -> HttpResponseBase:
|
|
390
|
+
"""Stop the template timer."""
|
|
391
|
+
timings: Timer | None = self._scope_get('timings')
|
|
392
|
+
if settings.STATSD_TRACK_MIDDLEWARE and timings:
|
|
393
|
+
timings.stop('process_template_response')
|
|
394
|
+
return response
|
|
395
|
+
|
|
396
|
+
def cleanup(self, request: HttpRequest) -> None:
|
|
397
|
+
"""Clear the scope so the next request starts fresh."""
|
|
398
|
+
self.scope.timings = None
|
|
399
|
+
self.scope.counter = None
|
|
400
|
+
self.scope.counter_codes = None
|
|
401
|
+
self.scope.counter_site = None
|
|
402
|
+
self.scope.view_name = None
|
|
403
|
+
request.statsd = None # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class StatsdMiddlewareTimer:
|
|
407
|
+
"""Closes the timers :class:`StatsdMiddleware` opened.
|
|
408
|
+
|
|
409
|
+
Goes at the bottom of ``MIDDLEWARE``. Between the pair they time
|
|
410
|
+
every middleware you install in between.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def __init__(self, get_response: GetResponse) -> None:
|
|
414
|
+
"""Store the next callable in the middleware chain."""
|
|
415
|
+
self.get_response = get_response
|
|
416
|
+
|
|
417
|
+
def __call__(self, request: HttpRequest) -> HttpResponseBase:
|
|
418
|
+
"""Time the inner half of the stack."""
|
|
419
|
+
self.process_request(request)
|
|
420
|
+
return self.process_response(request, self.get_response(request))
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _timings() -> Timer | None:
|
|
424
|
+
if settings.STATSD_TRACK_MIDDLEWARE:
|
|
425
|
+
return getattr(StatsdMiddleware.scope, 'timings', None)
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
def process_request(self, request: HttpRequest) -> None:
|
|
429
|
+
"""Stop the inbound timer the tracker started."""
|
|
430
|
+
timings = self._timings()
|
|
431
|
+
if timings:
|
|
432
|
+
timings.stop('process_request')
|
|
433
|
+
|
|
434
|
+
def process_view(
|
|
435
|
+
self,
|
|
436
|
+
request: HttpRequest,
|
|
437
|
+
view_func: Callable[..., HttpResponseBase],
|
|
438
|
+
view_args: tuple[Any, ...],
|
|
439
|
+
view_kwargs: dict[str, Any],
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Stop the view timer."""
|
|
442
|
+
timings = self._timings()
|
|
443
|
+
if timings:
|
|
444
|
+
timings.stop('process_view')
|
|
445
|
+
|
|
446
|
+
def process_response(
|
|
447
|
+
self,
|
|
448
|
+
request: HttpRequest,
|
|
449
|
+
response: HttpResponseBase,
|
|
450
|
+
) -> HttpResponseBase:
|
|
451
|
+
"""Start the outbound timer the tracker will stop."""
|
|
452
|
+
timings = self._timings()
|
|
453
|
+
if timings:
|
|
454
|
+
timings.start('process_response')
|
|
455
|
+
return response
|
|
456
|
+
|
|
457
|
+
def process_exception(
|
|
458
|
+
self,
|
|
459
|
+
request: HttpRequest,
|
|
460
|
+
exception: BaseException,
|
|
461
|
+
) -> None:
|
|
462
|
+
"""Start the exception timer the tracker will stop."""
|
|
463
|
+
timings = self._timings()
|
|
464
|
+
if timings:
|
|
465
|
+
timings.start('process_exception')
|
|
466
|
+
|
|
467
|
+
def process_template_response(
|
|
468
|
+
self,
|
|
469
|
+
request: HttpRequest,
|
|
470
|
+
response: HttpResponseBase,
|
|
471
|
+
) -> HttpResponseBase:
|
|
472
|
+
"""Start the template timer the tracker will stop."""
|
|
473
|
+
timings = self._timings()
|
|
474
|
+
if timings:
|
|
475
|
+
timings.start('process_template_response')
|
|
476
|
+
return response
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class DummyWith:
|
|
480
|
+
"""Stands in for :class:`WithTimer` outside a tracked request.
|
|
481
|
+
|
|
482
|
+
Lets the module-level helpers be used anywhere without the caller
|
|
483
|
+
checking whether a request is being timed.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
def __enter__(self) -> None:
|
|
487
|
+
"""Do nothing."""
|
|
488
|
+
|
|
489
|
+
def __exit__(
|
|
490
|
+
self,
|
|
491
|
+
type_: type[BaseException] | None,
|
|
492
|
+
value: BaseException | None,
|
|
493
|
+
traceback: TracebackType | None,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""Do nothing."""
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _timings() -> Timer | None:
|
|
499
|
+
return getattr(StatsdMiddleware.scope, 'timings', None)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _counter() -> Counter | None:
|
|
503
|
+
return getattr(StatsdMiddleware.scope, 'counter', None)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def start(key: str) -> None:
|
|
507
|
+
"""Start timing `key` in the current scope, if there is one."""
|
|
508
|
+
timings = _timings()
|
|
509
|
+
if timings:
|
|
510
|
+
timings.start(key)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def stop(key: str) -> float | None:
|
|
514
|
+
"""Stop timing `key`.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
The elapsed seconds, or ``None`` outside a tracked request.
|
|
518
|
+
|
|
519
|
+
"""
|
|
520
|
+
timings = _timings()
|
|
521
|
+
if timings:
|
|
522
|
+
return timings.stop(key)
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def with_(key: str) -> WithTimer | DummyWith:
|
|
527
|
+
"""Return a context manager timing `key`.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
A timer inside a tracked request, and a no-op outside one.
|
|
531
|
+
|
|
532
|
+
"""
|
|
533
|
+
timings = _timings()
|
|
534
|
+
if timings:
|
|
535
|
+
return timings(key)
|
|
536
|
+
return DummyWith()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def incr(key: str, value: int = 1) -> None:
|
|
540
|
+
"""Add `value` to the counter `key`, if a scope is open."""
|
|
541
|
+
counter = _counter()
|
|
542
|
+
if counter:
|
|
543
|
+
counter.increment(key, value)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def decr(key: str, value: int = 1) -> None:
|
|
547
|
+
"""Subtract `value` from the counter `key`, if a scope is open."""
|
|
548
|
+
counter = _counter()
|
|
549
|
+
if counter:
|
|
550
|
+
counter.decrement(key, value)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def wrapper(prefix: str, f: Callable[P, T]) -> Callable[P, T]:
|
|
554
|
+
"""Wrap `f` so each call is timed as ``<prefix>.<function name>``."""
|
|
555
|
+
# Not every Callable exposes __name__ (e.g. functools.partial or a
|
|
556
|
+
# callable instance), so fall back to the type name instead of
|
|
557
|
+
# assuming a plain function.
|
|
558
|
+
name = getattr(f, '__name__', type(f).__name__)
|
|
559
|
+
|
|
560
|
+
@functools.wraps(f)
|
|
561
|
+
def _wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
562
|
+
with with_(f'{prefix}.{name.lower()}'):
|
|
563
|
+
return f(*args, **kwargs)
|
|
564
|
+
|
|
565
|
+
return _wrapper
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def named_wrapper(name: str, f: Callable[P, T]) -> Callable[P, T]:
|
|
569
|
+
"""Wrap `f` so each call is timed as `name`."""
|
|
570
|
+
|
|
571
|
+
@functools.wraps(f)
|
|
572
|
+
def _wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
573
|
+
with with_(name):
|
|
574
|
+
return f(*args, **kwargs)
|
|
575
|
+
|
|
576
|
+
return _wrapper
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def decorator(prefix: str) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
580
|
+
"""Return a decorator timing each call under `prefix`."""
|
|
581
|
+
return functools.partial(wrapper, prefix)
|
django_statsd/py.typed
ADDED
|
File without changes
|
django_statsd/redis.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Patch :class:`redis.Redis` to time commands as ``redis.<command>``."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from django_statsd import middleware
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import redis
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
redis = None # type: ignore[assignment]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if (
|
|
15
|
+
# mypy infers `redis` as non-Optional here because the `except
|
|
16
|
+
# ImportError: redis = None` assignment above is itself a type
|
|
17
|
+
# error (silenced via ignore[assignment]) and so does not widen
|
|
18
|
+
# the declared type; the guard is still required at runtime for
|
|
19
|
+
# environments where the optional `redis` dependency is missing.
|
|
20
|
+
redis is not None # type: ignore[redundant-expr]
|
|
21
|
+
and not getattr(redis.Redis, 'statsd_patched', False)
|
|
22
|
+
):
|
|
23
|
+
# The pre-patch class, kept as a handle for tests and for anything
|
|
24
|
+
# that needs to reach past the patch. The class below subclasses
|
|
25
|
+
# `redis.Redis` by name so its base stays statically resolvable.
|
|
26
|
+
_original_redis = redis.Redis
|
|
27
|
+
|
|
28
|
+
class StatsdRedis(redis.Redis):
|
|
29
|
+
"""A :class:`redis.Redis` that times every command it runs."""
|
|
30
|
+
|
|
31
|
+
statsd_patched = True
|
|
32
|
+
|
|
33
|
+
def execute_command(self, *args: Any, **kwargs: Any) -> Any:
|
|
34
|
+
"""Run a command, timed as ``redis.<command>``."""
|
|
35
|
+
name = str(args[0]).lower() if args else 'unknown'
|
|
36
|
+
with middleware.with_(f'redis.{name}'):
|
|
37
|
+
# redis ships py.typed but leaves execute_command
|
|
38
|
+
# unannotated. Casting keeps the unknown from leaking
|
|
39
|
+
# into this method's return value.
|
|
40
|
+
# mypy already reads it as Callable, hence the
|
|
41
|
+
# redundant-cast suppression.
|
|
42
|
+
call = cast( # type: ignore[redundant-cast]
|
|
43
|
+
'Callable[..., Any]', super().execute_command
|
|
44
|
+
)
|
|
45
|
+
return call(*args, **kwargs)
|
|
46
|
+
|
|
47
|
+
redis.Redis = StatsdRedis # type: ignore[misc] # ty: ignore[invalid-assignment]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Django settings wrappers with safe defaults for django-statsd."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.core import exceptions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_setting(key: str, default: Any = None) -> Any:
|
|
10
|
+
"""Read `key` from Django's settings.
|
|
11
|
+
|
|
12
|
+
Returns `default` when Django is not configured yet, so importing
|
|
13
|
+
this module never raises.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
return getattr(settings, key, default)
|
|
18
|
+
except exceptions.ImproperlyConfigured:
|
|
19
|
+
return default
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: Enable tracking all requests using the middleware
|
|
23
|
+
STATSD_TRACK_MIDDLEWARE = get_setting('STATSD_TRACK_MIDDLEWARE', False)
|
|
24
|
+
|
|
25
|
+
#: Set the global statsd prefix if needed. Otherwise use the root
|
|
26
|
+
STATSD_PREFIX = get_setting('STATSD_PREFIX')
|
|
27
|
+
|
|
28
|
+
#: Enable warnings such as timers which are started but not finished.
|
|
29
|
+
#: Defaults to ``DEBUG`` if not configured
|
|
30
|
+
STATSD_DEBUG = get_setting('STATSD_DEBUG', get_setting('DEBUG'))
|
|
31
|
+
|
|
32
|
+
#: Statsd disabled mode, avoids sending metrics to the real server.
|
|
33
|
+
#: Useful for debugging purposes.
|
|
34
|
+
STATSD_DISABLED = get_setting('STATSD_DISABLED', False)
|
|
35
|
+
|
|
36
|
+
#: Enable creating tags as well as the bare version. This causes an ajax
|
|
37
|
+
#: view to be stored both as the regular view name and as the ajax tag.
|
|
38
|
+
#: Supported separators are ``_is_`` and ``=``
|
|
39
|
+
STATSD_TAGS_LIKE = get_setting('STATSD_TAGS_LIKE')
|
|
40
|
+
|
|
41
|
+
#: Statsd host, defaults to 127.0.0.1
|
|
42
|
+
STATSD_HOST = get_setting('STATSD_HOST', '127.0.0.1')
|
|
43
|
+
|
|
44
|
+
#: Statsd port, defaults to 8125
|
|
45
|
+
STATSD_PORT = get_setting('STATSD_PORT', 8125)
|
|
46
|
+
|
|
47
|
+
#: Statsd sample rate, lowering this decreases the (random) odds of
|
|
48
|
+
#: actually submitting the data. Between 0 and 1 where 1 means always
|
|
49
|
+
STATSD_SAMPLE_RATE = get_setting('STATSD_SAMPLE_RATE', 1.0)
|
|
50
|
+
|
|
51
|
+
#: List of regular expressions of views to skip
|
|
52
|
+
STATSD_VIEWS_TO_SKIP = get_setting(
|
|
53
|
+
'STATSD_VIEWS_TO_SKIP',
|
|
54
|
+
[
|
|
55
|
+
r'django.contrib.admin',
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
#: Track database query timings via ``connection.execute_wrapper``. The
|
|
60
|
+
#: middleware wraps every configured connection for the duration of each
|
|
61
|
+
#: request and submits ``sql.<alias>`` timings.
|
|
62
|
+
STATSD_TRACK_DATABASE = get_setting('STATSD_TRACK_DATABASE', False)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Time Django template rendering as ``render_django`` metrics."""
|
|
2
|
+
|
|
3
|
+
from django.template import loader
|
|
4
|
+
|
|
5
|
+
from django_statsd import middleware
|
|
6
|
+
|
|
7
|
+
if not hasattr(loader, 'statsd_patched'):
|
|
8
|
+
# Monkeypatching django.template.loader: none of the checkers know
|
|
9
|
+
# about `statsd_patched`, and ty additionally resolves
|
|
10
|
+
# `render_to_string` to its concrete signature, so the wrapped
|
|
11
|
+
# callable is not assignable there either.
|
|
12
|
+
loader.statsd_patched = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
|
|
13
|
+
loader.render_to_string = middleware.named_wrapper( # ty: ignore[invalid-assignment]
|
|
14
|
+
'render_django', loader.render_to_string
|
|
15
|
+
)
|
django_statsd/utils.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Helpers to build python-statsd connections and clients."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import statsd
|
|
6
|
+
|
|
7
|
+
from django_statsd import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_connection(
|
|
11
|
+
host: str | None = None,
|
|
12
|
+
port: int | None = None,
|
|
13
|
+
sample_rate: float | None = None,
|
|
14
|
+
disabled: bool | None = None,
|
|
15
|
+
) -> Any:
|
|
16
|
+
"""Build a python-statsd connection, defaulting to the settings."""
|
|
17
|
+
if not host:
|
|
18
|
+
host = settings.STATSD_HOST
|
|
19
|
+
|
|
20
|
+
if not port:
|
|
21
|
+
port = settings.STATSD_PORT
|
|
22
|
+
|
|
23
|
+
if not sample_rate:
|
|
24
|
+
sample_rate = settings.STATSD_SAMPLE_RATE
|
|
25
|
+
|
|
26
|
+
if not disabled:
|
|
27
|
+
disabled = settings.STATSD_DISABLED
|
|
28
|
+
|
|
29
|
+
return statsd.Connection(host, port, sample_rate, disabled)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_client(
|
|
33
|
+
name: str,
|
|
34
|
+
connection: Any = None,
|
|
35
|
+
class_: type[Any] = statsd.Client,
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Build a python-statsd client of `class_` named `name`."""
|
|
38
|
+
if not connection:
|
|
39
|
+
connection = get_connection()
|
|
40
|
+
|
|
41
|
+
return class_(name, connection)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_timer(name: str, connection: Any = None) -> Any:
|
|
45
|
+
"""Build a :class:`statsd.Timer` named `name`."""
|
|
46
|
+
return get_client(name, connection, statsd.Timer)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_counter(name: str, connection: Any = None) -> Any:
|
|
50
|
+
"""Build a :class:`statsd.Counter` named `name`."""
|
|
51
|
+
return get_client(name, connection, statsd.Counter)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-statsd
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Django app that submits query and view durations to statsd.
|
|
5
|
+
Author: Rick van Hattem
|
|
6
|
+
Author-email: Rick van Hattem <Wolph@Wol.ph>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 5.2
|
|
13
|
+
Classifier: Framework :: Django :: 6.0
|
|
14
|
+
Classifier: Framework :: Django :: 6.1
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Dist: asgiref>=3.6
|
|
28
|
+
Requires-Dist: django>=5.2
|
|
29
|
+
Requires-Dist: python-statsd>=2.1
|
|
30
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'assets'
|
|
31
|
+
Requires-Dist: pillow>=10.0 ; extra == 'assets'
|
|
32
|
+
Requires-Dist: playwright>=1.40 ; extra == 'assets'
|
|
33
|
+
Requires-Dist: django-statsd[tests] ; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.15.0 ; extra == 'dev'
|
|
35
|
+
Requires-Dist: mypy>=1.14 ; extra == 'dev'
|
|
36
|
+
Requires-Dist: django-stubs[compatible-mypy]>=5.1 ; extra == 'dev'
|
|
37
|
+
Requires-Dist: basedpyright>=1.0 ; extra == 'dev'
|
|
38
|
+
Requires-Dist: pyrefly>=0.1 ; extra == 'dev'
|
|
39
|
+
Requires-Dist: ty>=0.0.1 ; extra == 'dev'
|
|
40
|
+
Requires-Dist: sphinx>=8.0 ; extra == 'docs'
|
|
41
|
+
Requires-Dist: furo>=2025.1 ; extra == 'docs'
|
|
42
|
+
Requires-Dist: sphinxcontrib-mermaid>=1.0 ; extra == 'docs'
|
|
43
|
+
Requires-Dist: pytest>=8.0 ; extra == 'tests'
|
|
44
|
+
Requires-Dist: pytest-django>=4.8 ; extra == 'tests'
|
|
45
|
+
Requires-Dist: pytest-cov>=6.0 ; extra == 'tests'
|
|
46
|
+
Requires-Dist: coverage>=7.0 ; extra == 'tests'
|
|
47
|
+
Requires-Dist: celery>=5.3 ; extra == 'tests'
|
|
48
|
+
Requires-Dist: redis>=5.0 ; extra == 'tests'
|
|
49
|
+
Requires-Dist: tox>=4.0 ; extra == 'tox'
|
|
50
|
+
Requires-Dist: tox-uv>=1.0 ; extra == 'tox'
|
|
51
|
+
Requires-Dist: tox-gh-actions>=3.0 ; extra == 'tox'
|
|
52
|
+
Requires-Python: >=3.10
|
|
53
|
+
Project-URL: Homepage, https://github.com/WoLpH/django-statsd/
|
|
54
|
+
Project-URL: Documentation, https://django-stats.readthedocs.io/
|
|
55
|
+
Project-URL: Changelog, https://github.com/WoLpH/django-statsd/blob/master/CHANGELOG.md
|
|
56
|
+
Provides-Extra: assets
|
|
57
|
+
Provides-Extra: dev
|
|
58
|
+
Provides-Extra: docs
|
|
59
|
+
Provides-Extra: tests
|
|
60
|
+
Provides-Extra: tox
|
|
61
|
+
Description-Content-Type: text/markdown
|
|
62
|
+
|
|
63
|
+
<p align="center">
|
|
64
|
+
<img src="https://raw.githubusercontent.com/WoLpH/django-statsd/master/docs/images/logo.png" alt="django-statsd" width="420">
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
<p align="center">
|
|
68
|
+
<a href="https://github.com/WoLpH/django-statsd/actions/workflows/ci.yml"><img src="https://github.com/WoLpH/django-statsd/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
69
|
+
<a href="https://pypi.org/project/django-statsd/"><img src="https://img.shields.io/pypi/v/django-statsd" alt="PyPI"></a>
|
|
70
|
+
<a href="https://pypi.org/project/django-statsd/"><img src="https://img.shields.io/pypi/pyversions/django-statsd" alt="Python"></a>
|
|
71
|
+
<a href="https://pypi.org/project/django-statsd/"><img src="https://img.shields.io/pypi/dm/django-statsd" alt="Downloads"></a>
|
|
72
|
+
<a href="https://django-stats.readthedocs.io/en/latest/"><img src="https://readthedocs.org/projects/django-stats/badge/?version=latest" alt="Documentation"></a>
|
|
73
|
+
<a href="https://github.com/WoLpH/django-statsd/blob/master/LICENSE"><img src="https://img.shields.io/pypi/l/django-statsd" alt="License"></a>
|
|
74
|
+
</p>
|
|
75
|
+
|
|
76
|
+
Two middleware entries, and every view, query, template and Celery task
|
|
77
|
+
in your Django project reports its duration to
|
|
78
|
+
[statsd](https://github.com/statsd/statsd).
|
|
79
|
+
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
That recording is real. So is every transcript below: they are produced
|
|
83
|
+
by running the code and checked by the test suite, so a claim on this
|
|
84
|
+
page cannot outlive the behaviour it describes.
|
|
85
|
+
|
|
86
|
+
## Quick Start
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install django-statsd
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
INSTALLED_APPS = [
|
|
94
|
+
'django_statsd',
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
MIDDLEWARE = [
|
|
98
|
+
'django_statsd.middleware.StatsdMiddleware',
|
|
99
|
+
'django_statsd.middleware.StatsdMiddlewareTimer',
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
STATSD_HOST = '127.0.0.1'
|
|
103
|
+
STATSD_PORT = 8125
|
|
104
|
+
STATSD_PREFIX = 'myproject'
|
|
105
|
+
STATSD_TRACK_MIDDLEWARE = True
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The tracker goes at the top of `MIDDLEWARE` and the timer at the
|
|
109
|
+
bottom. Your own middlewares go between them, and the pair times
|
|
110
|
+
everything in between.
|
|
111
|
+
|
|
112
|
+
## What you get
|
|
113
|
+
|
|
114
|
+
One `GET /dashboard/` puts this on the wire:
|
|
115
|
+
|
|
116
|
+
<!-- transcript: views -->
|
|
117
|
+
```text
|
|
118
|
+
myproject.view.get.myproject.views.dashboard.hit
|
|
119
|
+
myproject.view.get.myproject.views.dashboard.process_request
|
|
120
|
+
myproject.view.get.myproject.views.dashboard.process_response
|
|
121
|
+
myproject.view.get.myproject.views.dashboard.process_view
|
|
122
|
+
myproject.view.get.myproject.views.dashboard.total
|
|
123
|
+
myproject.view.http_codes.2xx
|
|
124
|
+
myproject.view.http_codes.hit
|
|
125
|
+
myproject.view.site.hit
|
|
126
|
+
```
|
|
127
|
+
<!-- /transcript -->
|
|
128
|
+
|
|
129
|
+
`total` is the request, the three `process_*` names are the phases
|
|
130
|
+
inside it, and `hit` is a counter so you get a rate per view without
|
|
131
|
+
dividing anything. The last two are project-wide.
|
|
132
|
+
|
|
133
|
+
## Features
|
|
134
|
+
|
|
135
|
+
- **Views** timed by method and dotted view path, with status classes
|
|
136
|
+
counted separately
|
|
137
|
+
- **Queries** timed through Django's `execute_wrapper`, nested under
|
|
138
|
+
the view that ran them, so you get query time per view
|
|
139
|
+
- **Celery tasks** timed per task, plus a counter for every signal
|
|
140
|
+
Celery exports
|
|
141
|
+
- **Templates, `json` and `redis`** patched on import and timed
|
|
142
|
+
without a line of configuration
|
|
143
|
+
- **Your own code**, timed through `request.statsd` or the
|
|
144
|
+
module-level helpers, nested under the view that was running
|
|
145
|
+
- **Async safe.** The scope lives on `asgiref.local.Local`, so ASGI
|
|
146
|
+
and async views report correctly
|
|
147
|
+
|
|
148
|
+
## Requirements
|
|
149
|
+
|
|
150
|
+
- Python 3.10 through 3.14
|
|
151
|
+
- Django 5.2, 6.0 or 6.1
|
|
152
|
+
|
|
153
|
+
Celery and redis are optional. django-statsd patches them if they
|
|
154
|
+
import and does nothing if they don't.
|
|
155
|
+
|
|
156
|
+
## Where your milliseconds go
|
|
157
|
+
|
|
158
|
+

|
|
159
|
+
|
|
160
|
+
Recorded from a real request. The phases don't sum to
|
|
161
|
+
`total`, and the gap is the part of the request outside the section
|
|
162
|
+
the middleware pair wraps.
|
|
163
|
+
|
|
164
|
+
## Usage Examples
|
|
165
|
+
|
|
166
|
+
Time a block inside a view through `request.statsd`:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
def some_view(request):
|
|
170
|
+
with request.statsd.timings('build_queryset'):
|
|
171
|
+
...
|
|
172
|
+
|
|
173
|
+
def some_other_view(request):
|
|
174
|
+
request.statsd.timings.start('build_queryset')
|
|
175
|
+
...
|
|
176
|
+
request.statsd.timings.stop('build_queryset')
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Or reach the same scope from anywhere during a tracked request, and
|
|
180
|
+
from inside a Celery task:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import django_statsd
|
|
184
|
+
|
|
185
|
+
with django_statsd.with_('payment.authorise'):
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
django_statsd.incr('payment.attempt')
|
|
189
|
+
|
|
190
|
+
@django_statsd.decorator('payment')
|
|
191
|
+
def authorise():
|
|
192
|
+
...
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Both land nested inside the view that was running, so the same helper
|
|
196
|
+
called from two views gives you two series.
|
|
197
|
+
|
|
198
|
+
## In a dashboard
|
|
199
|
+
|
|
200
|
+

|
|
201
|
+
|
|
202
|
+
statsd, Graphite and Grafana, fed by django-statsd over UDP. The
|
|
203
|
+
compose file and the provisioning that produced this are in
|
|
204
|
+
`docs/generate/dashboard/`.
|
|
205
|
+
|
|
206
|
+
## Settings
|
|
207
|
+
|
|
208
|
+
| Setting | Default | What it does |
|
|
209
|
+
| --- | --- | --- |
|
|
210
|
+
| `STATSD_HOST` | `127.0.0.1` | Where to send |
|
|
211
|
+
| `STATSD_PORT` | `8125` | Which port |
|
|
212
|
+
| `STATSD_PREFIX` | none | Nests every metric under one name |
|
|
213
|
+
| `STATSD_TRACK_MIDDLEWARE` | `False` | The view metrics |
|
|
214
|
+
| `STATSD_TRACK_DATABASE` | `False` | Query timings |
|
|
215
|
+
| `STATSD_SAMPLE_RATE` | `1.0` | Odds a metric is really sent |
|
|
216
|
+
| `STATSD_VIEWS_TO_SKIP` | admin | Regexes of views to ignore |
|
|
217
|
+
| `STATSD_DISABLED` | `False` | Loaded and silent |
|
|
218
|
+
| `STATSD_DEBUG` | `DEBUG` | Warn about unstopped timers |
|
|
219
|
+
|
|
220
|
+
Full list with docstrings:
|
|
221
|
+
[settings reference](https://django-stats.readthedocs.io/en/latest/reference/settings.html).
|
|
222
|
+
|
|
223
|
+
## Documentation
|
|
224
|
+
|
|
225
|
+
- [Documentation](https://django-stats.readthedocs.io/en/latest/)
|
|
226
|
+
- [Metric reference](https://django-stats.readthedocs.io/en/latest/reference/metrics.html)
|
|
227
|
+
- [Changelog](https://github.com/WoLpH/django-statsd/blob/master/CHANGELOG.md)
|
|
228
|
+
|
|
229
|
+
## Contributing
|
|
230
|
+
|
|
231
|
+
See [CONTRIBUTING.md](https://github.com/WoLpH/django-statsd/blob/master/CONTRIBUTING.md).
|
|
232
|
+
Every code sample on this page is executed by the test suite, so a
|
|
233
|
+
change to the API that breaks an example fails the build.
|
|
234
|
+
|
|
235
|
+
## Links
|
|
236
|
+
|
|
237
|
+
- [Source](https://github.com/WoLpH/django-statsd)
|
|
238
|
+
- [Issues](https://github.com/WoLpH/django-statsd/issues)
|
|
239
|
+
- [PyPI](https://pypi.org/project/django-statsd/)
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
[BSD 3-Clause](https://github.com/WoLpH/django-statsd/blob/master/LICENSE)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
django_statsd/__init__.py,sha256=cPKLckzVLJEr8ygBrvG6S_Yopn1NlYz3JiOxCoQFc4o,541
|
|
2
|
+
django_statsd/celery.py,sha256=2h4Cv-eE2-H9HkL9T8gJyTS6oRfVnUtONIcHoNyBX-c,1985
|
|
3
|
+
django_statsd/database.py,sha256=lXlcxh-J9Jg2Y7tLAD5VGniBVnNLtDWiAz5pURdAado,1103
|
|
4
|
+
django_statsd/json.py,sha256=gnGd0KJ2k8ZQOprpm7vnLC9Z2SUtV9aFVe8N6ZayI-4,1146
|
|
5
|
+
django_statsd/middleware.py,sha256=0YN3-tlUOt6uL6yLhC0g2gx5taOH9T3dfTJACtP8nZI,18523
|
|
6
|
+
django_statsd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
django_statsd/redis.py,sha256=kvza60PyMU86SLUR2lSaM4zi1s5XKHD2D-IGks7SXac,1951
|
|
8
|
+
django_statsd/settings.py,sha256=HhUkTA4VrQ6c2sX982cMGA6xVfMxhqnvsIsdWNjSHl4,2111
|
|
9
|
+
django_statsd/templates.py,sha256=p1fpQulLP3j8rIT20exnYGhgqkhNR03yKCFbpvEEFGI,672
|
|
10
|
+
django_statsd/utils.py,sha256=5QKe_KwaUdy1itU-Co79T1v7z7c_CD9ajoJYABkY-1s,1297
|
|
11
|
+
django_statsd-3.0.0.dist-info/licenses/LICENSE,sha256=UX7NzXkqWry4CAkM5cI43h1cCZJnWw3gbnEeOVw-9pk,1536
|
|
12
|
+
django_statsd-3.0.0.dist-info/WHEEL,sha256=-i9oRNYVXXZJUIYl5zclLIg6onEb0NLibTX34uln84w,81
|
|
13
|
+
django_statsd-3.0.0.dist-info/METADATA,sha256=K8FaRHkQOV7-G7Fc0ScOgsGF3AJKX3zO8WM5Mb80ncE,8899
|
|
14
|
+
django_statsd-3.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2012-2026, Rick van Hattem (Wolph)
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
23
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
24
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
25
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
26
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
27
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
28
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
29
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
30
|
+
POSSIBILITY OF SUCH DAMAGE.
|