Skip to content

Commit 597d1f7

Browse files
committed
restructure listener and analyzer to simplify test implementation
1 parent 1a83752 commit 597d1f7

File tree

5 files changed

+87
-76
lines changed

5 files changed

+87
-76
lines changed

README.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
Django SQL Sniffer
22
==================
33
A simple command line tool for analyzing Django ORM SQL execution from a running process.
4-
Minimally invasive and granular - no need to change logging config or restart the process.
4+
Minimally invasive and granular - no need to change the logging config or the source code
5+
of the target process, and no need to restart the process.
56

67
# Usage
78
Install though pip
89
```
910
pip install django-sql-sniffer
1011
```
11-
Run the tool by passing it a process id which is to be analyzed
12+
Run the tool by passing it the target process id
1213
```
1314
django-sql-sniffer -p 76441
1415
```
15-
`Ctrl + C` to stop and show the query stats summary. Here's a short demo:
16-
![https://raw.githubusercontent.com/gruuya/django-sql-sniffer/master/demo.webp](demo.webp)
16+
`Ctrl + C` to stop and show the query stats summary. Take a look at a short demo:
17+
![demo](demo.webp)
1718
By default, stats summary shows queries sorted by max duration; the possible options include:
1819
- `-t` print queries in tail mode, i.e. as they are executed
1920
- `-c` sort stats summary by query count

django_sql_sniffer/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version = "1.0.2"
1+
version = "1.0.3.dev0"

django_sql_sniffer/analyzer.py

+6-37
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import threading
21
try:
32
import sqlparse
43
except ImportError:
54
sqlparse = None
6-
from django_sql_sniffer import sniffer, version
5+
from django_sql_sniffer import version
76

87

98
SQL_STATS_TEXT = """
@@ -26,17 +25,13 @@ def format_duration(duration):
2625
return "{0:0.9f}".format(duration)
2726

2827

29-
class SQLAnalyzer(threading.Thread):
30-
def __init__(self, conn, verbose=False, tail=False, top=3, by_sum=False, by_count=False):
31-
super().__init__(name=self.__class__.__name__)
32-
self._conn = conn
28+
class SQLAnalyzer:
29+
def __init__(self, tail=False, top=3, by_sum=False, by_count=False):
3330
self._executed_queries = dict()
3431
self._tail = tail
3532
self._top = top
3633
self._by_sum = by_sum
3734
self._by_count = by_count
38-
self._running = False
39-
self.logger = sniffer.configure_logger(__name__, verbose)
4035

4136
def record_query(self, sql, duration):
4237
if sql in self._executed_queries:
@@ -50,6 +45,9 @@ def record_query(self, sql, duration):
5045
sum=duration
5146
)
5247

48+
if self._tail:
49+
self.print_query(sql, duration)
50+
5351
def print_query(self, sql, duration):
5452
stats = self._executed_queries[sql]
5553
print("Count: ", stats["count"], "; Duration: ", format_duration(duration), "; Max Duration: ", format_duration(stats["max"]), "; Query:", sep="")
@@ -70,32 +68,3 @@ def print_summary(self, *a, **kw):
7068
print(format_sql(sql))
7169
print("-" * DELIMETER_LENGTH)
7270
print("=" * DELIMETER_LENGTH)
73-
74-
def start(self):
75-
self.logger.debug("starting")
76-
self._running = True
77-
super().start()
78-
79-
def stop(self, *a, **kw):
80-
self.logger.debug("stopping")
81-
self._running = False
82-
83-
def run(self):
84-
while self._running:
85-
try:
86-
if self._conn.poll(3):
87-
sql_packet = self._conn.recv()
88-
duration = sql_packet["duration"]
89-
sql = sql_packet["sql"]
90-
self.record_query(sql, duration)
91-
if self._tail:
92-
self.print_query(sql, duration)
93-
except EOFError:
94-
self.logger.info("sniffer disconnected, exiting")
95-
self._running = False
96-
except Exception as e:
97-
self.logger.error(f"unexpected error: {str(e)}", exc_info=True)
98-
self._running = False
99-
100-
self._conn.close()
101-
self.logger.debug("done")

django_sql_sniffer/listener.py

+72-33
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,75 @@
11
import argparse
22
import signal
33
import time
4+
import threading
45
from multiprocessing.connection import Listener
56
from django_sql_sniffer import analyzer, injector, sniffer
67

78

9+
class DjangoSQLListener(threading.Thread):
10+
def __init__(self, cmdline_args):
11+
super().__init__(name=self.__class__.__name__)
12+
self.analyzer = analyzer.SQLAnalyzer(tail=cmdline_args.tail, top=cmdline_args.number, by_sum=cmdline_args.sum, by_count=cmdline_args.count)
13+
self.logger = sniffer.configure_logger(__name__, cmdline_args.verbose)
14+
self._target_pid = cmdline_args.pid
15+
self._verbose = cmdline_args.verbose
16+
self._listener = None
17+
self._conn = None
18+
self._running = False
19+
20+
def _create_socket(self):
21+
self._listener = Listener(('localhost', 0)) # will force OS to assign a random available port
22+
_, self.port = self._listener.address
23+
self.logger.debug(f"listening on port {self.port}")
24+
25+
def _inject_sniffer(self):
26+
with open(sniffer.__file__, "r") as source_file:
27+
source_code = source_file.read()
28+
code_to_inject = source_code.replace("PORT = 0", f"PORT = {self.port}")
29+
code_to_inject = code_to_inject.replace("LOGGING_ENABLED = False", f"LOGGING_ENABLED = {self._verbose}")
30+
code_to_inject += "sniffer.start()"
31+
injector.inject(str(self._target_pid), code_to_inject, self._verbose)
32+
self.logger.debug("SQL sniffer injected, waiting for a reply")
33+
34+
def _callback_wait(self):
35+
self._conn = self._listener.accept()
36+
self._listener.close()
37+
self.logger.debug("reply received, sniffer active")
38+
39+
def start(self):
40+
self.logger.debug("starting")
41+
self._create_socket()
42+
self._inject_sniffer()
43+
self._callback_wait()
44+
self._running = True
45+
super().start()
46+
47+
def stop(self, *a, **kw):
48+
self.logger.debug("stopping")
49+
self._running = False
50+
51+
def run(self):
52+
while self._running:
53+
try:
54+
if self._conn.poll(3):
55+
sql_packet = self._conn.recv()
56+
duration = sql_packet["duration"]
57+
sql = sql_packet["sql"]
58+
self.analyzer.record_query(sql, duration)
59+
except EOFError:
60+
self.logger.info("sniffer disconnected, exiting")
61+
self._running = False
62+
except Exception as e:
63+
self.logger.error(f"unexpected error: {str(e)}", exc_info=True)
64+
self._running = False
65+
66+
self.analyzer.print_summary()
67+
self._conn.close()
68+
injector.inject(str(self._target_pid), "sniffer.stop()", self._verbose)
69+
self.logger.debug("done")
70+
71+
872
def main():
9-
# parse arguments
1073
parser = argparse.ArgumentParser(description="Analyze SQL queries originating from a running process")
1174
parser.add_argument("-p", "--pid", help="The id of the process executing Django SQL queries")
1275
parser.add_argument("-t", "--tail", action='store_true', help="Log queries as they are executed in tail mode")
@@ -15,39 +78,15 @@ def main():
1578
parser.add_argument("-c", "--count", action='store_true', help="Sort query summary by execution count")
1679
parser.add_argument("-n", "--number", type=int, default=3, help="Number of top queries and their stats to display in summary")
1780
args = parser.parse_args()
18-
logger = sniffer.configure_logger(__name__, args.verbose)
19-
20-
# create socket and listen for a connection
21-
listener = Listener(('localhost', 0)) # will force OS to assign a random available port
22-
_, port = listener.address
23-
logger.debug(f"listening on port {port}")
24-
25-
# inject sniffer monkey patch
26-
with open(sniffer.__file__, "r") as source_file:
27-
source_code = source_file.read()
28-
code_to_inject = source_code.replace("PORT = 0", f"PORT = {port}")
29-
code_to_inject = code_to_inject.replace("LOGGING_ENABLED = False", f"LOGGING_ENABLED = {args.verbose}")
30-
code_to_inject += "sniffer.start()"
31-
injector.inject(str(args.pid), code_to_inject, args.verbose)
32-
33-
# wait for callback
34-
logger.debug("SQL sniffer injected, waiting for a reply")
35-
conn = listener.accept()
36-
listener.close()
37-
38-
# receive and analyze executed SQL
39-
logger.debug("reply received, sniffer active")
40-
sql_analyzer = analyzer.SQLAnalyzer(conn, verbose=args.verbose, tail=args.tail, top=args.number, by_sum=args.sum, by_count=args.count)
41-
signal.signal(signal.SIGTERM, sql_analyzer.stop)
42-
signal.signal(signal.SIGINT, sql_analyzer.stop)
43-
if hasattr(signal, "SIGINFO"):
44-
signal.signal(signal.SIGINFO, sql_analyzer.print_summary) # print summary on ^T, without exiting the process
45-
sql_analyzer.start()
4681

47-
while sql_analyzer.is_alive():
48-
time.sleep(0.5)
49-
injector.inject(str(args.pid), "sniffer.stop()", args.verbose)
50-
sql_analyzer.print_summary()
82+
listener = DjangoSQLListener(args)
83+
listener.start()
84+
signal.signal(signal.SIGTERM, listener.stop)
85+
signal.signal(signal.SIGINT, listener.stop)
86+
if hasattr(signal, "SIGINFO"):
87+
signal.signal(signal.SIGINFO, listener.analyzer.print_summary) # print summary on ^T, without exiting the process
88+
while listener.is_alive():
89+
time.sleep(1)
5190

5291

5392
if __name__ == "__main__":

setup.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import django_sql_sniffer
33

44

5+
URL = 'https://github.com/gruuya/django-sql-sniffer'
56
with open("README.md", "r", encoding="utf-8") as fh:
67
long_description = fh.read()
8+
long_description = long_description.replace('![demo](demo.webp)', f'[demo]({URL})\\')
79

810

911
setuptools.setup(
@@ -13,7 +15,7 @@
1315
long_description=long_description,
1416
long_description_content_type='text/markdown',
1517
keywords='django sql query remote process analysis',
16-
url='https://github.com/gruuya/django-sql-sniffer',
18+
url=URL,
1719
author='Marko Grujic',
1820
author_email='[email protected]',
1921
license='MIT',

0 commit comments

Comments
 (0)