From 9714499074596a3d781d30e34568ba9b6dcc58ef Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Sun, 29 Jun 2025 11:56:21 +0530 Subject: [PATCH 1/9] Add dashboard UI, WebSocket broadcasting, and DB persistence --- pyquerytracker/api.py | 49 ++++++++++++++++ pyquerytracker/config.py | 3 + pyquerytracker/core.py | 16 ++++++ pyquerytracker/db/models.py | 18 ++++++ pyquerytracker/db/querytracker.db | Bin 0 -> 12288 bytes pyquerytracker/db/session.py | 6 ++ pyquerytracker/db/writer.py | 25 ++++++++ pyquerytracker/main.py | 6 ++ pyquerytracker/tracker.py | 17 ++++++ pyquerytracker/websocket.py | 25 ++++++++ requirements-dev.txt | 5 +- templates/dashboard.html | 91 ++++++++++++++++++++++++++++++ tests/test_dashboard.py | 29 ++++++++++ tests/test_persist.py | 8 +++ tests/test_websocket.py | 59 +++++++++++++++++++ 15 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 pyquerytracker/api.py create mode 100644 pyquerytracker/db/models.py create mode 100644 pyquerytracker/db/querytracker.db create mode 100644 pyquerytracker/db/session.py create mode 100644 pyquerytracker/db/writer.py create mode 100644 pyquerytracker/main.py create mode 100644 pyquerytracker/tracker.py create mode 100644 pyquerytracker/websocket.py create mode 100644 templates/dashboard.html create mode 100644 tests/test_dashboard.py create mode 100644 tests/test_persist.py create mode 100644 tests/test_websocket.py diff --git a/pyquerytracker/api.py b/pyquerytracker/api.py new file mode 100644 index 0000000..bdd6f17 --- /dev/null +++ b/pyquerytracker/api.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI, Request, Query +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates +from collections import defaultdict +from pyquerytracker.tracker import get_tracked_queries # βœ… real-time logs +from pyquerytracker.config import get_config +from pyquerytracker.websocket import websocket_endpoint +from fastapi import WebSocket + +app = FastAPI(title="Query Tracker API") + +templates = Jinja2Templates(directory="templates") + + +if get_config().dashboard_enabled: + @app.get("/dashboard", response_class=HTMLResponse) + def dashboard(request: Request): + return templates.TemplateResponse("dashboard.html", {"request": request}) + + +@app.get("/api/query-stats") +def get_query_stats(minutes: int = Query(5, ge=1, le=1440)): + logs = get_tracked_queries(minutes) + + # You can change this list to match real endpoints you're tracking + all_endpoints = ["GET /users", "POST /items", "GET /items", "DELETE /items"] + durations_by_endpoint = defaultdict(list) + + for log in logs: + # fallback if endpoint is not tracked explicitly + endpoint = log.get("endpoint") or log.get("function_name") or "UNKNOWN" + durations_by_endpoint[endpoint].append(log.get("duration_ms", 0)) + + # Build chart-ready response + return { + "labels": all_endpoints, + "durations": [ + round( + sum(durations_by_endpoint.get(ep, [])) / max(1, len(durations_by_endpoint.get(ep, []))), + 2 + ) + for ep in all_endpoints + ] + } + + +@app.websocket("/ws") +async def websocket_route(websocket: WebSocket): + await websocket_endpoint(websocket) diff --git a/pyquerytracker/config.py b/pyquerytracker/config.py index ba071e4..eaf5132 100644 --- a/pyquerytracker/config.py +++ b/pyquerytracker/config.py @@ -37,6 +37,9 @@ class Config: slow_log_level: int = logging.WARNING export_type: Optional[ExportType] = None export_path: Optional[str] = None + dashboard_enabled: bool = True # ← set to False in real deployments + persist_to_db: bool = True + _config: Config = Config() diff --git a/pyquerytracker/core.py b/pyquerytracker/core.py index 0916204..a512e20 100644 --- a/pyquerytracker/core.py +++ b/pyquerytracker/core.py @@ -7,6 +7,10 @@ from pyquerytracker.exporter.base import NullExporter from pyquerytracker.exporter.manager import ExporterManager from pyquerytracker.utils.logger import QueryLogger +from pyquerytracker.tracker import store_tracked_query +from pyquerytracker.db.writer import DBWriter + + logger = QueryLogger.get_logger() @@ -91,6 +95,9 @@ async def async_wrapped(*args: Any, **kwargs: Any) -> T: extra=log_data, ) self.exporter.append(log_data) + if self.config.persist_to_db: + DBWriter.save(log_data) + store_tracked_query(log_data) return result except Exception as e: duration = (time.perf_counter() - start) * 1000 @@ -114,6 +121,9 @@ async def async_wrapped(*args: Any, **kwargs: Any) -> T: extra=log_data, ) self.exporter.append(log_data) + if self.config.persist_to_db: + DBWriter.save(log_data) + store_tracked_query(log_data) return None return update_wrapper(async_wrapped, func) @@ -161,6 +171,9 @@ def wrapped(*args: Any, **kwargs: Any) -> T: extra=log_data, ) self.exporter.append(log_data) + if self.config.persist_to_db: + DBWriter.save(log_data) + store_tracked_query(log_data) return result except Exception as e: @@ -185,6 +198,9 @@ def wrapped(*args: Any, **kwargs: Any) -> T: extra=log_data, ) self.exporter.append(log_data) + if self.config.persist_to_db: + DBWriter.save(log_data) + store_tracked_query(log_data) return None return update_wrapper(wrapped, func) diff --git a/pyquerytracker/db/models.py b/pyquerytracker/db/models.py new file mode 100644 index 0000000..819aa1c --- /dev/null +++ b/pyquerytracker/db/models.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime +from sqlalchemy.orm import declarative_base +from datetime import datetime + +Base = declarative_base() + +class TrackedQuery(Base): + __tablename__ = "tracked_queries" + + id = Column(Integer, primary_key=True, index=True) + function_name = Column(String) + class_name = Column(String, nullable=True) + duration_ms = Column(Float) + timestamp = Column(DateTime, default=datetime.utcnow) + event = Column(String) # "slow_execution", "normal_execution", "error" + func_args = Column(String) + func_kwargs = Column(String) + error = Column(String, nullable=True) diff --git a/pyquerytracker/db/querytracker.db b/pyquerytracker/db/querytracker.db new file mode 100644 index 0000000000000000000000000000000000000000..92fc55dcf8e3345ce30095a25295f6db6bd2c434 GIT binary patch literal 12288 zcmeI%F>ljA6bJA-yC{k!8aj1hxTR8~kSKQRkY=H#IUvGKz!*eXEE8YRk=Oy-fgl7D z@QwHc%zO(bCRQc}&JmF+r!X+W|4BaE&+ne?-{$kZyROUyJDsI*n6o`{iBL)o86$*j zs7+VfDl~OagYCu2$0uX=z1Rwwb2tWV=5P-mcBQVh_ z+gBU(9hFHW7IG2fX*iyUD0n^>sT5ftqf$mc^0?!3HW>E#Lw4>AHX5?hEVe77`uoeY zvfXIV53@XcJQbyq(y8|AKE89^dSBQT109e)qsEi1Rwwb2tWV=5P$## zAOHafK;W+hT+JXmq~bKUwn(Nv@TX$A((&pV|NOM8x>{|^Znis3tIJHQXLfq~cB|Vl z+XtPb_$@yT#6pbc#e%_Z{q-A{{-#_1>$gv(Zf4Iu= List[Dict[str, Any]]: + """Return all tracked queries within the last `minutes`.""" + cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes) + return [log for log in query_data_store if log["timestamp"] >= cutoff] diff --git a/pyquerytracker/websocket.py b/pyquerytracker/websocket.py new file mode 100644 index 0000000..ff7f78b --- /dev/null +++ b/pyquerytracker/websocket.py @@ -0,0 +1,25 @@ +from fastapi import WebSocket, WebSocketDisconnect +from typing import List + +connected_clients: List[WebSocket] = [] + +async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + connected_clients.append(websocket) + try: + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + connected_clients.remove(websocket) + +async def broadcast(message: str): + disconnected = [] + for client in connected_clients: + try: + await client.send_text(message) + except: + disconnected.append(client) + for client in disconnected: + connected_clients.remove(client) + diff --git a/requirements-dev.txt b/requirements-dev.txt index 70a5a7c..98c9a1e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,4 +4,7 @@ flake8 black isort pylint -pytest-asyncio \ No newline at end of file +pytest-asyncio +fastapi +uvicorn +httpx \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..bf5386c --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,91 @@ + + + + + Query Dashboard + + + + +

Query Execution Time

+ + + + +
+ +
+ + + + diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..38f4846 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,29 @@ +import pytest +from fastapi.testclient import TestClient +from pyquerytracker.api import app +from pyquerytracker.core import TrackQuery + +client = TestClient(app) + +# Simulate query activity +@TrackQuery() +def sample_query(): + return "ok" + +def test_query_stats_endpoint(): + # Trigger a few logs + for _ in range(3): + sample_query() + + # Call the stats endpoint + response = client.get("/api/query-stats?minutes=5") + assert response.status_code == 200 + + json = response.json() + assert "labels" in json + assert "durations" in json + assert isinstance(json["labels"], list) + assert isinstance(json["durations"], list) + + # Optional: Print results for debug + print("πŸ“Š Dashboard API response:", json) diff --git a/tests/test_persist.py b/tests/test_persist.py new file mode 100644 index 0000000..af8c526 --- /dev/null +++ b/tests/test_persist.py @@ -0,0 +1,8 @@ + +from pyquerytracker import TrackQuery + +@TrackQuery() +def sample_query(): + return "DB test successful" + +sample_query() diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..a461225 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,59 @@ +import asyncio +from starlette.testclient import TestClient +from pyquerytracker.api import app +from pyquerytracker.websocket import ( + connected_clients, + broadcast, + websocket_endpoint +) + + +def test_websocket_connection(): + client = TestClient(app) + with client.websocket_connect("/ws") as websocket: + websocket.send_text("ping") # No response expected, just test it works + + +def test_broadcast_message_format(): + class FakeWebSocket: + def __init__(self): + self.sent = [] + + async def send_text(self, msg): + self.sent.append(msg) + + fake_ws = FakeWebSocket() + connected_clients.append(fake_ws) + + asyncio.run(broadcast("hello")) + assert fake_ws.sent == ["hello"] + + connected_clients.remove(fake_ws) + + +def test_connection_lifecycle(): + class DummyWebSocket: + def __init__(self): + self.accepted = False + + async def accept(self): + self.accepted = True + + async def receive_text(self): + raise Exception("Simulated disconnect") + + ws = DummyWebSocket() + connected_clients.clear() + try: + asyncio.run(websocket_endpoint(ws)) + except: + pass + assert ws not in connected_clients + + +def test_broadcast_no_clients(): + connected_clients.clear() + try: + asyncio.run(broadcast("no one here")) + except Exception: + assert False, "Broadcast failed when no clients connected" From 91707e8c784103e5e93e7f61ec38e1d170e22c58 Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Sun, 29 Jun 2025 16:18:44 +0530 Subject: [PATCH 2/9] Optimized - Dashboard --- pyquerytracker/db/querytracker.db | Bin 12288 -> 24576 bytes pyquerytracker/db/session.py | 1 - pyquerytracker/exporter/json_exporter.py | 2 +- pyquerytracker/websocket.py | 2 +- templates/dashboard.html | 516 +++++++++++++++++++++-- tests/exporter/test_json_exporter.py | 26 +- tests/test_dashboard.py | 1 - tests/test_websocket.py | 2 +- 8 files changed, 498 insertions(+), 52 deletions(-) diff --git a/pyquerytracker/db/querytracker.db b/pyquerytracker/db/querytracker.db index 92fc55dcf8e3345ce30095a25295f6db6bd2c434..741af1978a9912eb4e555b1b431ba74a8ef4ef40 100644 GIT binary patch literal 24576 zcmeHO32+kE4y!RC5|N9vYkXe*p?$v&TiShV5mGuG zSk2^%ENj30-}nFT|KI!fd273Gcq(Wg8lTu3n6h)GT$9;sa@*}Dlc^v7OUM6WFAIK? z8v6nNZ;pGf_qN~UIM{2$zqL?PChAQL#>-H^P{2^YP{2^YP{2^YP{2^YP{2^YP~c0W zz=$QIK&mu{%)?`Y!2`nw{8JNwfsx>#|DyfDiQ(X+e|S*!NlIOZ*VE~>Z)&OccH4

m6hhp$EO1Qqe0bIR6kmx_v!T1`n*~?Yu4}}wxQG8=o5lr-t_iC#M2?_u1?5P&+p@d*RdI#lf+uct*IDKQOU-QuE=+wEE-V#KibS{P)^A zHZ^-XdhA=gJz;UI6=^u?Ec8bZd+GGQB5%GlfoKfWP{2^YP{2^YP{2^YP{2^YP{2^Y zP{2^YP~eNMKw38*Vd`2d#y;B#6MdHch(1HVOP{7+r(dC;r=O;uppVlJ(f89oq3@(` zr*EQfps%B^p|7A1(g)}XdXyfb`{-V}lWw7#=vsOMEz%6VmM*1N(o5(CbPkfSWb}HZ=h@ zHUc&@0D8TE_4R;tb%3?CfF2K^+YRV)0dCv~SW^SIVFTd$^?*(%prQcEGN2>@iXxyO z0P;K_#{nV)$g+S81GsJ-pu+)JT@6@O1z1@LxOOdIMFn7aIpCT#fU8#nmX!gPmI9WP z02UVmu380Xw*wXx0j^vLxMBrhVIko1<$%kU0WMt%xMT@nK>=WXKH%cTfQuFZE?fw> zU;$uW9$;=RAWZ{O6rjxpn3DsToegNU0%m0aW@Z9rWB{h81E!?`rlta>qySnhDJfZ5 za5jIf=_(U_A3aFtQ%_T0r|PK`+mp7dY;4ZUIm@z7X0NthWG&A6b=H-cZ)AQWvo7Or z89&U}nXxkc#q?w8-RT8sC((enDkS>e7Fx$X3FqX z=jE9u@ezp8VE8P^jLX;h*h$QS7$j6PVl`6ijN(L`lM@{rE6PY5gFjpt^#>0G2lm5} zit>uDT;{9LVe_20`9@tf9!WBhtz3t#tq2{~Wn+1kBe8`_)?}NOFULa-DS4*BasTxA z#7Of@9h?(!Z$G-_x3^c3N6SbeBWM^mOyM&l{(kW@z?2>Ty$5qZ8sZfcP&kg0nT=I;RAuKt!PQpN8tjjPyu4LI zf#FeLgu^*At`SS?B|3vfEJBJBK63(I=){)U!#_2bMcBZ*LxGW?f3QEw=05W-CL<0Q zF_O$8&44j+bEzI(-CJ+%)1~7Do)|45RFZ8f!yc&!wTC@2IXXU_;FjU6QP<<&BU;HS z3NMJ^E9OEl=9ICa;oT9p42;A3{S!fde_(QWAmW|T@!g~Nc+cpkbP(d=926=lj)YM~ zNYI4Qp<3_r>9-K<6&#AfNs4f;_I?%%+g4dkEDoUx>~tLF;adhKXYk=Ah zxQRCMGM?A6%0}$>kkgLGTn1BTXWW=QKpL4prT%M=|HDosd9WHoV-8C4ydQIXq(=asv(^HSP-m~6TzAtcYvtMGm8 zbF*vf$_Vr9?26FF=9s&5-!;!v_K}$d264Pb6l|_*h1Enyy%%sbw0-|Dab5`yB#Vr! zXr1LOj1b@qvbf*1>qF}cx&)je3$vpGZCuOaZ{GOWBjgf*Z!XD-#A$EdxXfxQidrB3 zEgYJ2mmV#D@>|D=LNEf)GLn|4W~tS*Fygg>{=kGQcu&Qh#BuNrnc-MzZXDqOH7&uH zFT`_TDNcOj&cUM9wIsAS{4PB8AvuI+Bt;~ySekS7=xU$Mb?egclB8X(Ko&=IvT~d0zocb|XeEd1m#m&5 zF{k{O%5Bs^s&Y%sQ*P_l{9)A$(Oy>JM3Glb>7@21E4L&I+idff+q}caPM;#!C_6-1 z5)mPZhMYOc%58s}<4hlsRFLt?h}Be<&!gN%)Cdd=C6wFjB<1#C{zR3VY`VxxGMb%N znsRGRQf>$8cfG8`%!{H#OjE9`_=?x`o!>l2oL91g$MLTa)<(|E_;TC#&;b0QwsxKa z0V^`h?C8SfHY5J#?#o{zr&)ZhEHe_JN;akw%kA`sn^k5|+{emoicS$~I&#;AM2jScpy0I>F@yRnI&9vj z_Q>&UvYkavR&?@$Ll#*^As2764qN@(?;lsOLFIOGJTBe>%V^6PPRFqScbIN9(GSqm zw1aw|`YC=2zuNY;?H1ck+p3&XIXC3^vOmebKYM?6mGw{7d#q#DrCGnqvSi+yNoDk8 zSkr%*zBld3w3)QZ)Ze8ZP4%Xxr#zB!X^PwOZ_AG?ZRYFE9!&n(U!kJzc@nR;>p)j_ ze3-M0ljof*mQP`jq+XVucjE2Gbz4BefDpG6YwMan@fLpfox)tAjhu}0f!ao>kk0MN z5^vqMKbM>$k|I1sS?Pb0c%y2tC?WA~OOkkZznNE>kRE4!hD3=K+1b>jNxYp&5^q=U zR+NzR=9mQ}@Z`k1H9q&%-z^@-u^N{*=O`Fa;0Vrgb|fU;t8ERs1U#>Zv!e?q-uC#L zdk$UsoX)l3E`vH%;Yew0BPQN4aqW)d#8EJ;EOF}XKPIYaRV7|F>m{Nsc%OrKMVK2$ zB=NRr60g6^f1?hA$TN6#ty&bt5^u9ka;vL9a;FX-k3$}5xAu5gKA#R-`-16L64`KD zkz<`K=Rlk&2#RI`0oxWGwnlE%$GU8sA`la=Z?g_t?Hey96oqihGiDoJ^AkrJD%ljt zlf<^-wRhFN{Z4#9%#9XFf}kxku}pa$`y7$!4-TQ|vMknCY|`8340SwmyN-k`hj7no zcH+eCa~kzFChiB&aXmI9%VY_g8}#U04_4o%OD8fSF|&k_H`zWXD}D3L#YDHT2v`4e z-Zz|ETQTaOgklyds-LIa)}1{xeI3DGKIu=B_`?6&F9Fm1?b@P|o+?nz> zP7-b88KjU_uOVk`vU1ybb3s}kkrY!hvUYxd9{ZfA8me;ZNm6c))Knizs0Gofk8tM> zi3&G6uQcV>ouu48vAFntJ6RM_P{_S@mn**FZ994&Id5X)@PbuDs(`{d*ts#G+)g)k z5+^1QAYPnn$A!ynP5jM0eA>Nw*D?}bE@*GwxPe%1`%a?>F~K7}KWkH5Kla$&jdwT#S?B90?Q-1N5Ye09X3@F#N}hyUT7r-=+4ZiOp?q7!j0CSDR^m?6A_ zlkwc)iRs$8F5Vx3im$EIS`j5cxCJ>obHe^`#NXU|@B@6QDuYFDj^l|XOx(t5qCe{M zdr}S)i6jX>$|9`XYpN3cv1g=>*Rcg}J49Z+;I7p8qxaTF7wIr?tbzn}-zGZKVE><) z^Q?*9LY<}Vru?>F*hXwC@CU}rP{2^YP{2^YP{2^YP{2^YP{2^YP~iVt0bid^L&Lr9 z?+38y+DAL01L3{5NbdOib=WpOd`Q*Sh(0I+HW9JfjygO)*rmf(f73-*>)OU~EcqNw z$SG&p2F)^}B+PXt z<2wEq=(XrKzMiM*=FK{U#Rv0bqC=W{_2|~uwc2%!gOgIE=R+aXldR*Pnsxm*MWS1v VWg@E^o-wEBcF@nQ<4<~g{U2Q<*1Z4# delta 82 zcmZoTz}S#5L7J6?fq{W}qJljm%f^HS{5-rsE)#zW1OF!el+A(y5&TR(Op_I diff --git a/pyquerytracker/db/session.py b/pyquerytracker/db/session.py index f3d96ed..0ece93a 100644 --- a/pyquerytracker/db/session.py +++ b/pyquerytracker/db/session.py @@ -1,6 +1,5 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from pyquerytracker.config import get_config engine = create_engine("sqlite:///pyquerytracker/db/querytracker.db", connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/pyquerytracker/exporter/json_exporter.py b/pyquerytracker/exporter/json_exporter.py index 804d89c..2779f73 100644 --- a/pyquerytracker/exporter/json_exporter.py +++ b/pyquerytracker/exporter/json_exporter.py @@ -41,7 +41,7 @@ def flush(self): existing_data.extend(self._buffer) with open(self.config.export_path, "w", encoding="utf-8") as f: - json.dump(existing_data, f, indent=2) + json.dump(existing_data, f, indent=2, default=str) logger.info(f"Flushed {len(self._buffer)} logs to JSON") self._buffer.clear() diff --git a/pyquerytracker/websocket.py b/pyquerytracker/websocket.py index ff7f78b..c0c61f4 100644 --- a/pyquerytracker/websocket.py +++ b/pyquerytracker/websocket.py @@ -18,7 +18,7 @@ async def broadcast(message: str): for client in connected_clients: try: await client.send_text(message) - except: + except Exception: disconnected.append(client) for client in disconnected: connected_clients.remove(client) diff --git a/templates/dashboard.html b/templates/dashboard.html index bf5386c..52d7ee8 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -2,90 +2,514 @@ + Query Dashboard - + -

Query Execution Time

+
+
+

Query Dashboard

+

Real-time monitoring of query execution performance

+
+ +
+
+ + +
+ +
+ + +
+
- - +
+
+
0
+
Total Queries
+
+
+
0ms
+
Avg Duration
+ +
+
+
0
+
Errors
+
+
-
- +
+ +
+ +
- + \ No newline at end of file diff --git a/tests/exporter/test_json_exporter.py b/tests/exporter/test_json_exporter.py index 23e5e74..1972520 100644 --- a/tests/exporter/test_json_exporter.py +++ b/tests/exporter/test_json_exporter.py @@ -5,7 +5,17 @@ def run_test_in_subprocess(script: str, export_path: str): - subprocess.run(["python3", "-c", script], check=True) + print("\n----- Running Script -----\n") + print(script) + print("\n--------------------------\n") + + try: + subprocess.run(["python3", "-c", script], check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as e: + print("STDOUT:\n", e.stdout) + print("STDERR:\n", e.stderr) + raise + assert os.path.exists(export_path) with open(export_path) as f: return json.load(f) @@ -32,6 +42,8 @@ def foo(x, y): return x + y foo(1, 2) +from pyquerytracker.exporter.manager import ExporterManager +ExporterManager.get().flush() """ logs = run_test_in_subprocess(script, export_path) @@ -68,6 +80,10 @@ def bar(): bar() except RuntimeError: pass + +from pyquerytracker.exporter.manager import ExporterManager +ExporterManager.get().flush() + """ logs = run_test_in_subprocess(script, export_path) @@ -101,6 +117,10 @@ def slow_func(): return "done" slow_func() + +from pyquerytracker.exporter.manager import ExporterManager +ExporterManager.get().flush() + """ logs = run_test_in_subprocess(script, export_path) @@ -141,6 +161,10 @@ def b(): except Exception: pass a() + +from pyquerytracker.exporter.manager import ExporterManager +ExporterManager.get().flush() + """ logs = run_test_in_subprocess(script, export_path) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 38f4846..121f790 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,4 +1,3 @@ -import pytest from fastapi.testclient import TestClient from pyquerytracker.api import app from pyquerytracker.core import TrackQuery diff --git a/tests/test_websocket.py b/tests/test_websocket.py index a461225..d341433 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -46,7 +46,7 @@ async def receive_text(self): connected_clients.clear() try: asyncio.run(websocket_endpoint(ws)) - except: + except Exception: pass assert ws not in connected_clients From 3cdadf2506efb1932d6934542a1114d70702abbc Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Mon, 30 Jun 2025 02:07:30 +0530 Subject: [PATCH 3/9] fixed linting issues and passed all style, test checks --- pyquerytracker/config.py | 3 +-- pyquerytracker/core.py | 2 -- pyquerytracker/db/models.py | 1 + pyquerytracker/db/querytracker.db | Bin 24576 -> 24576 bytes pyquerytracker/db/writer.py | 1 + pyquerytracker/main.py | 3 +-- pyquerytracker/websocket.py | 3 ++- requirements-dev.txt | 3 ++- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyquerytracker/config.py b/pyquerytracker/config.py index eaf5132..a2562d5 100644 --- a/pyquerytracker/config.py +++ b/pyquerytracker/config.py @@ -37,11 +37,10 @@ class Config: slow_log_level: int = logging.WARNING export_type: Optional[ExportType] = None export_path: Optional[str] = None - dashboard_enabled: bool = True # ← set to False in real deployments + dashboard_enabled: bool = True # ← set to False in real deployments persist_to_db: bool = True - _config: Config = Config() diff --git a/pyquerytracker/core.py b/pyquerytracker/core.py index a512e20..4c65830 100644 --- a/pyquerytracker/core.py +++ b/pyquerytracker/core.py @@ -10,8 +10,6 @@ from pyquerytracker.tracker import store_tracked_query from pyquerytracker.db.writer import DBWriter - - logger = QueryLogger.get_logger() T = TypeVar("T") diff --git a/pyquerytracker/db/models.py b/pyquerytracker/db/models.py index 819aa1c..242fa02 100644 --- a/pyquerytracker/db/models.py +++ b/pyquerytracker/db/models.py @@ -4,6 +4,7 @@ Base = declarative_base() + class TrackedQuery(Base): __tablename__ = "tracked_queries" diff --git a/pyquerytracker/db/querytracker.db b/pyquerytracker/db/querytracker.db index 741af1978a9912eb4e555b1b431ba74a8ef4ef40..997f8f1e7f3dc600fcb884d3ea6cfe4ea6ed7235 100644 GIT binary patch delta 1348 zcmZ{jOH30%9L9HPO0_Ux!=ox_SB(e>G`q7qyR)d&)&~y(F(Jk$?UuH*^hHYrBQd~1 z6O9^hJYb^20WStYLgFhVLSkY(p;zNYPsW4M#DfP-+^J$PZZE&g;hXRO{lCeKEU_a? z>{tamFkZQz9hmn`J4_kSLM+^JHCP0Xz(a5!OoO{%0^9&s!6h&P&Vvj{fH3fb)8H8B z04?*WlZC@(6U$~Yl>Pmb=`>|3McLO!nM_h95|r^cWh_P+jZ*gZQbr<_;V@-S4`nDs z84OYe0+ij|lzu;@rcwHQ=7m>vySMMDH2aw1!$m)e9u#@OQxFAP3Ren83zdTJ1#<<7 z0%-key>0cZzqnppH*R@nxn^m||B~Ng{$ZXq`{q-eIj=QC#t+!HwFPbtF$@EFi0XK` z4yqjF>qVg+sZLoHF%rpprHt**YMW*z>vC!Z38`6aI;$OQ-gRXuhcCuhfV#HSG`b^2 z7SB|0V^uBXbH*XCr0U{Xp|c!`nyNrKDDe%o9IWNUnxPA=eMIZpxGny2=W>o|2&)1{ zzXAQr$W{putjM$ngxP!A3x1LQI8Hh4g6{H*R1h~1?F8(p;h>}y%R?gP- z_~2!OF0m8KqKF_Fb(gZeWLDz1lm5`pkA_kOsSqLZSqiS^g$m7!YB_<|dp{l_%gqi) zsH(luXKWxz0w!ag3N}n;JPuB4c;WkItb~e;F)e-A)njFf8H>BdW@9v0dwA-(VfWZ6 zD=HME{#bI*8$1{6KAVXo6V-aG`p|C!yUkYScX>^3_UAN^^|ubD)5$cw7t$gzSJ1t7 z`k|Rm6FDzK0jd8_Zw!#BwzA<%?>09+F_#4IK>6v2nKyv(H>H0}p-|Pyhe` diff --git a/pyquerytracker/db/writer.py b/pyquerytracker/db/writer.py index 0f1aa39..6b3afd1 100644 --- a/pyquerytracker/db/writer.py +++ b/pyquerytracker/db/writer.py @@ -2,6 +2,7 @@ from pyquerytracker.db.models import TrackedQuery from sqlalchemy.exc import SQLAlchemyError + class DBWriter: @staticmethod def save(log_data: dict): diff --git a/pyquerytracker/main.py b/pyquerytracker/main.py index 5ed5401..dbcf758 100644 --- a/pyquerytracker/main.py +++ b/pyquerytracker/main.py @@ -1,6 +1,5 @@ if __name__ == "__main__": import uvicorn - from pyquerytracker.api import app + from pyquerytracker.api import app uvicorn.run(app, host="127.0.0.1", port=8000) - diff --git a/pyquerytracker/websocket.py b/pyquerytracker/websocket.py index c0c61f4..6f1a2da 100644 --- a/pyquerytracker/websocket.py +++ b/pyquerytracker/websocket.py @@ -3,6 +3,7 @@ connected_clients: List[WebSocket] = [] + async def websocket_endpoint(websocket: WebSocket): await websocket.accept() connected_clients.append(websocket) @@ -13,6 +14,7 @@ async def websocket_endpoint(websocket: WebSocket): finally: connected_clients.remove(websocket) + async def broadcast(message: str): disconnected = [] for client in connected_clients: @@ -22,4 +24,3 @@ async def broadcast(message: str): disconnected.append(client) for client in disconnected: connected_clients.remove(client) - diff --git a/requirements-dev.txt b/requirements-dev.txt index 98c9a1e..d13e155 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,4 +7,5 @@ pylint pytest-asyncio fastapi uvicorn -httpx \ No newline at end of file +httpx +sqlalchemy \ No newline at end of file From 212a4745a0dc913fa3adb36e013d9a734c95fa68 Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Mon, 30 Jun 2025 03:01:59 +0530 Subject: [PATCH 4/9] fixed issues and passed all style, test checks --- pyquerytracker/api.py | 15 +++++++++------ pyquerytracker/db/session.py | 5 ++++- pyquerytracker/websocket.py | 3 ++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/pyquerytracker/api.py b/pyquerytracker/api.py index bdd6f17..b633928 100644 --- a/pyquerytracker/api.py +++ b/pyquerytracker/api.py @@ -1,11 +1,12 @@ -from fastapi import FastAPI, Request, Query +from collections import defaultdict + +from fastapi import FastAPI, Request, Query, WebSocket from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates -from collections import defaultdict + from pyquerytracker.tracker import get_tracked_queries # βœ… real-time logs from pyquerytracker.config import get_config from pyquerytracker.websocket import websocket_endpoint -from fastapi import WebSocket app = FastAPI(title="Query Tracker API") @@ -13,6 +14,7 @@ if get_config().dashboard_enabled: + @app.get("/dashboard", response_class=HTMLResponse) def dashboard(request: Request): return templates.TemplateResponse("dashboard.html", {"request": request}) @@ -36,11 +38,12 @@ def get_query_stats(minutes: int = Query(5, ge=1, le=1440)): "labels": all_endpoints, "durations": [ round( - sum(durations_by_endpoint.get(ep, [])) / max(1, len(durations_by_endpoint.get(ep, []))), - 2 + sum(durations_by_endpoint.get(ep, [])) + / max(1, len(durations_by_endpoint.get(ep, []))), + 2, ) for ep in all_endpoints - ] + ], } diff --git a/pyquerytracker/db/session.py b/pyquerytracker/db/session.py index 0ece93a..05fc7e5 100644 --- a/pyquerytracker/db/session.py +++ b/pyquerytracker/db/session.py @@ -1,5 +1,8 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -engine = create_engine("sqlite:///pyquerytracker/db/querytracker.db", connect_args={"check_same_thread": False}) +engine = create_engine( + "sqlite:///pyquerytracker/db/querytracker.db", + connect_args={"check_same_thread": False}, +) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/pyquerytracker/websocket.py b/pyquerytracker/websocket.py index 6f1a2da..75d6f89 100644 --- a/pyquerytracker/websocket.py +++ b/pyquerytracker/websocket.py @@ -1,6 +1,7 @@ -from fastapi import WebSocket, WebSocketDisconnect from typing import List +from fastapi import WebSocket, WebSocketDisconnect + connected_clients: List[WebSocket] = [] From 4eb99039a5eb52fef8e5eaa9310b64deea8d9330 Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Mon, 30 Jun 2025 11:29:40 +0530 Subject: [PATCH 5/9] Fixed issue between flake8 and black dependencies --- pyquerytracker/db/querytracker.db | Bin 24576 -> 28672 bytes requirements-dev.txt | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyquerytracker/db/querytracker.db b/pyquerytracker/db/querytracker.db index 997f8f1e7f3dc600fcb884d3ea6cfe4ea6ed7235..6489c56bc87744db6a231fbbba3dabd6a158923a 100644 GIT binary patch delta 2746 zcmZ{mYfKbZ6vyxGvMeljSXUm4U}Vt7fWkU+?>tFifuMo`v!+_Hrk0gLL{SvwE$j|j zpUA`Ufl926lJt|2P}8P0)>_l@!K6)^s_8>9HnD9|Lz<>(ZJT!Pg|@)X?1%H8O#b(r zb6>yN$SqrB!ZwiMya)hr4YC2-#nI|3oO%c%2{QE zm}PDw4ntOVu|EWvQl^km7{ufJxW&Qm=H&QTo# zLsZX#vsBN3GgME5(^OA^Q&e@JQ#}bzQXK??R0qHS)qc=VwGZ@BJpsZe=tD2)rFtA3 zr+N$=qk0q^rFsM$p?Vk`rg{h*qWT7SgX%$Wkm><&fNBrup{fCmY6L`_4x23;Cb7Gl z#I7z9bNBBj(%DI3M+b@R?IgCfk=WWwVoM8&&CO$L^4?$U$N@WaS5RHyoF6kDb2O&N z@wek+M~@@V{)Ii&cE^?lb{Km$`==qzra#)qvN6z`ejOkViFurqhdGuL^93#+OMU_I zqNM7XRa?@O+7O3vXlsZo%wp$%Ha>CRq8T|=Je_SHoD61m`dRDdQ-LrYM+jO zVwHfGWks!RYHDmEwvmiQ zNr@g<(CP+B#=MQTeOp6Kfo=B&IxT8*2rB~quW|ZN)?y4#GL~(D7t}Q_ZLHt5FYxN+ z?%k#-1ivgB=Rnp6WuL8CA1w7{t-5k3+rlds6VD2(qj2}LMm*FPpOZX~bD51pt)SVN5FCNEr#pSuab@jzQjU9?DcRnB8bHnMV z&=%6Fl|SAk0Gz$OBA+kteo>HR9+9ecErXcZwc7YKOS6bMs&<=51hwEYwwgy-!tY6cLAN>SkQn3N1|!ZAr^|ePK_ZML9vh zGDiA3lx)|0deO$TtkU355i2uRWl=U}o$RR8Qi+>gmGZ2}(k)5k(ik&AS_(x^eBScW z?WQvkFpmY9qGx(0A#uh`4D=*Z^b}|EzbrD%fRMp1fuQH#wV0J5s3lSM%>UrKvbj7U zd&>D`0xyZM$F9w%@Oi1Upsd=Y80VLGNl|$MpS0{P z;;6ao!u>l@=BW>@XxusxPvOHw%_TR>*ISx--ndAVHL=vZ{>hCVD>If=MI}dt@WVO9 zkFf`jZq8|Xx(|$zk(%&h`Wsp+%(!(GS`6jK-qy#TM&?d?`Y!`kkSb2`!$J8GT=9E@ cMRS62jCqkXXYj*L-4(~$#Nt diff --git a/requirements-dev.txt b/requirements-dev.txt index d13e155..26d9e22 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,4 +8,5 @@ pytest-asyncio fastapi uvicorn httpx -sqlalchemy \ No newline at end of file +sqlalchemy +jinja2 \ No newline at end of file From 683ac8477f270b7402b2c8358d744112c96b1250 Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Mon, 30 Jun 2025 18:30:16 +0530 Subject: [PATCH 6/9] Fixed issue of flake8 --- .flake8 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 552cb4c..679bdf3 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -ignore = E501 +ignore = E501,W503 max-line-length = 100 exclude = .git,__pycache__,env,venv,.venv,.mypy_cache,.pytest_cache,*.egg-info From 74cce1fcb28d19845865986ef17a2147ff496068 Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Mon, 30 Jun 2025 23:50:54 +0530 Subject: [PATCH 7/9] Fixed Lint issues and updated readme file --- README.md | 92 ++++++++++++-- pyquerytracker/api.py | 4 +- pyquerytracker/core.py | 176 +++++++++++---------------- pyquerytracker/db/models.py | 5 +- pyquerytracker/db/querytracker.db | Bin 28672 -> 81920 bytes pyquerytracker/db/writer.py | 5 +- pyquerytracker/main.py | 1 + pyquerytracker/tracker.py | 4 +- tests/exporter/test_json_exporter.py | 8 +- tests/test_async_core.py | 15 ++- tests/test_dashboard.py | 3 + tests/test_persist.py | 3 +- tests/test_websocket.py | 9 +- 13 files changed, 184 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 1357744..34f09b9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + ![GitHub Release](https://img.shields.io/github/v/release/MuddyHope/pyquerytracker) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/MuddyHope/pyquerytracker) @@ -33,8 +34,24 @@ pip install pyquerytracker ``` -## Usage +## πŸ”§ Configuration + +```python +import logging +from pyquerytracker.config import configure + +configure( + slow_log_threshold_ms=200, # Log queries slower than 200ms + slow_log_level=logging.DEBUG # Use DEBUG level for slow logs +) +``` + +--- + +## βš™οΈ Usage + ### Basic Usage + ```python import time from pyquerytracker import TrackQuery @@ -46,26 +63,79 @@ def run_query(): run_query() ``` -### Output -```bash + +**Output:** +``` 2025-06-14 14:23:00,123 - pyquerytracker - INFO - Function run_query executed successfully in 305.12ms ``` -### With Configure +--- + +### 🧩 Async Support + +Use the same decorator with `async` functions or class methods: + +```python +import asyncio +from pyquerytracker import TrackQuery + +@TrackQuery() +async def fetch_data(): + await asyncio.sleep(0.2) + return "fetched" + +class MyService: + @TrackQuery() + async def do_work(self, x, y): + await asyncio.sleep(0.1) + return x + y + +asyncio.run(fetch_data()) ``` -import logging + +--- + +### 🌐 Run the FastAPI Server + +To view tracked query logs via REST, WebSocket, or a Web-based dashboard, start the built-in FastAPI server: + +```bash +uvicorn pyquerytracker.server:app --reload +``` + +- Open docs at [http://localhost:8000/docs](http://localhost:8000/docs) +- **Query Dashboard UI:** [http://localhost:8000/dashboard](http://localhost:8000/dashboard) +- REST endpoint: `GET /queries` +- WebSocket stream: `ws://localhost:8000/ws` + +Then run your tracked functions in another terminal or script: + +```python +@TrackQuery() +def insert_query(): + time.sleep(0.4) + return "INSERT INTO users ..." +``` + +You’ll see logs live on the server via API/WebSocket. + +--- + +## πŸ“€ Export Logs + +Enable exporting to CSV or JSON by setting config: + +```python from pyquerytracker.config import configure configure( - slow_log_threshold_ms=200, # Log queries slower than 200ms - slow_log_level=logging.DEBUG # Use DEBUG level for slow logs + export_type="json", + export_path="./query_logs.json" ) ``` -### Output -```bash -2025-06-14 14:24:45,456 - pyquerytracker - WARNING - Slow execution: run_query took 501.87ms -``` +--- +Let us know how you’re using `pyquerytracker` and feel free to contribute! diff --git a/pyquerytracker/api.py b/pyquerytracker/api.py index b633928..d37c23d 100644 --- a/pyquerytracker/api.py +++ b/pyquerytracker/api.py @@ -1,11 +1,11 @@ from collections import defaultdict -from fastapi import FastAPI, Request, Query, WebSocket +from fastapi import FastAPI, Query, Request, WebSocket from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates -from pyquerytracker.tracker import get_tracked_queries # βœ… real-time logs from pyquerytracker.config import get_config +from pyquerytracker.tracker import get_tracked_queries # βœ… real-time logs from pyquerytracker.websocket import websocket_endpoint app = FastAPI(title="Query Tracker API") diff --git a/pyquerytracker/core.py b/pyquerytracker/core.py index 4c65830..6eddcd1 100644 --- a/pyquerytracker/core.py +++ b/pyquerytracker/core.py @@ -1,14 +1,14 @@ import asyncio import time from functools import update_wrapper -from typing import Any, Callable, Generic, TypeVar +from typing import Any, Callable, Generic, Optional, TypeVar from pyquerytracker.config import get_config +from pyquerytracker.db.writer import DBWriter from pyquerytracker.exporter.base import NullExporter from pyquerytracker.exporter.manager import ExporterManager -from pyquerytracker.utils.logger import QueryLogger from pyquerytracker.tracker import store_tracked_query -from pyquerytracker.db.writer import DBWriter +from pyquerytracker.utils.logger import QueryLogger logger = QueryLogger.get_logger() @@ -16,28 +16,6 @@ class TrackQuery(Generic[T]): - """ - Class-based decorator to track and log the execution time of functions or methods. - - Works with both synchronous and asynchronous functions. - - Logs include: - - Function name - - Class name (if method) - - Execution time (ms) - - Arguments - - Errors (if any) - - Usage: - @TrackQuery() - def my_function(): - ... - - @TrackQuery() - async def my_async_function(): - ... - """ - def __init__(self) -> None: self.config = get_config() if self.config.export_type and self.config.export_path: @@ -47,42 +25,63 @@ def __init__(self) -> None: else: self.exporter = NullExporter() + def _extract_class_name(self, args: Any) -> Optional[str]: + if args: + obj = args[0] + if hasattr(obj, "__class__"): + return obj.__name__ if isinstance(obj, type) else obj.__class__.__name__ + return None + + # pylint: disable=too-many-positional-arguments + def _build_log_data(self, func, class_name, duration, args, kwargs, error=None): + data = { + "event": ( + "error" + if error + else ( + "slow_execution" + if duration > self.config.slow_log_threshold_ms + else "normal_execution" + ) + ), + "function_name": func.__name__, + "class_name": class_name, + "duration_ms": duration, + "func_args": repr(args), + "func_kwargs": repr(kwargs), + } + if error: + data["error"] = str(error) + return data + + def _handle_export(self, log_data): + self.exporter.append(log_data) + if self.config.persist_to_db: + DBWriter.save(log_data) + store_tracked_query(log_data) + def __call__(self, func: Callable[..., T]) -> Callable[..., T]: if asyncio.iscoroutinefunction(func): async def async_wrapped(*args: Any, **kwargs: Any) -> T: start = time.perf_counter() - class_name = None - - if args: - possible_self_or_cls = args[0] - if hasattr(possible_self_or_cls, "__class__"): - if isinstance(possible_self_or_cls, type): - class_name = possible_self_or_cls.__name__ - else: - class_name = possible_self_or_cls.__class__.__name__ + class_name = self._extract_class_name(args) try: result = await func(*args, **kwargs) duration = (time.perf_counter() - start) * 1000 - log_data = { - "event": ( - "slow_execution" - if duration > self.config.slow_log_threshold_ms - else "normal_execution" - ), - "function_name": func.__name__, - "class_name": class_name, - "duration_ms": duration, - "func_args": repr(args), - "func_kwargs": repr(kwargs), - } + log_data = self._build_log_data( + func, class_name, duration, args, kwargs + ) if duration > self.config.slow_log_threshold_ms: logger.log( self.config.slow_log_level, - f"{class_name}.{func.__name__} -> " - f"Slow execution: took {duration:.2f}ms", + "%s%s -> Slow execution: took %.2fms", + f"{class_name}." if class_name else "", + func.__name__, + duration, + extra=log_data, ) else: logger.info( @@ -92,23 +91,15 @@ async def async_wrapped(*args: Any, **kwargs: Any) -> T: duration, extra=log_data, ) - self.exporter.append(log_data) - if self.config.persist_to_db: - DBWriter.save(log_data) - store_tracked_query(log_data) + + self._handle_export(log_data) return result + except Exception as e: duration = (time.perf_counter() - start) * 1000 - log_data = { - "event": "error", - "function_name": func.__name__, - "class_name": class_name, - "duration_ms": duration, - "func_args": repr(args), - "func_kwargs": repr(kwargs), - "error": str(e), - } - + log_data = self._build_log_data( + func, class_name, duration, args, kwargs, error=e + ) logger.error( "Function %s%s failed after %.2fms: %s", f"{class_name}." if class_name else "", @@ -118,47 +109,30 @@ async def async_wrapped(*args: Any, **kwargs: Any) -> T: exc_info=True, extra=log_data, ) - self.exporter.append(log_data) - if self.config.persist_to_db: - DBWriter.save(log_data) - store_tracked_query(log_data) + self._handle_export(log_data) return None return update_wrapper(async_wrapped, func) def wrapped(*args: Any, **kwargs: Any) -> T: start = time.perf_counter() - class_name = None - - if args: - possible_self_or_cls = args[0] - if hasattr(possible_self_or_cls, "__class__"): - if isinstance(possible_self_or_cls, type): - class_name = possible_self_or_cls.__name__ - else: - class_name = possible_self_or_cls.__class__.__name__ + class_name = self._extract_class_name(args) try: result = func(*args, **kwargs) duration = (time.perf_counter() - start) * 1000 - log_data = { - "event": ( - "slow_execution" - if duration > self.config.slow_log_threshold_ms - else "normal_execution" - ), - "function_name": func.__name__, - "class_name": class_name, - "duration_ms": duration, - "func_args": repr(args), - "func_kwargs": repr(kwargs), - } + log_data = self._build_log_data( + func, class_name, duration, args, kwargs + ) if duration > self.config.slow_log_threshold_ms: logger.log( self.config.slow_log_level, - f"{class_name}.{func.__name__} -> " - f"Slow execution: took {duration:.2f}ms", + "%s%s -> Slow execution: took %.2fms", + f"{class_name}." if class_name else "", + func.__name__, + duration, + extra=log_data, ) else: logger.info( @@ -168,24 +142,15 @@ def wrapped(*args: Any, **kwargs: Any) -> T: duration, extra=log_data, ) - self.exporter.append(log_data) - if self.config.persist_to_db: - DBWriter.save(log_data) - store_tracked_query(log_data) + + self._handle_export(log_data) return result except Exception as e: duration = (time.perf_counter() - start) * 1000 - log_data = { - "event": "error", - "function_name": func.__name__, - "class_name": class_name, - "duration_ms": duration, - "func_args": repr(args), - "func_kwargs": repr(kwargs), - "error": str(e), - } - + log_data = self._build_log_data( + func, class_name, duration, args, kwargs, error=e + ) logger.error( "Function %s%s failed after %.2fms: %s", f"{class_name}." if class_name else "", @@ -195,10 +160,7 @@ def wrapped(*args: Any, **kwargs: Any) -> T: exc_info=True, extra=log_data, ) - self.exporter.append(log_data) - if self.config.persist_to_db: - DBWriter.save(log_data) - store_tracked_query(log_data) + self._handle_export(log_data) return None return update_wrapper(wrapped, func) diff --git a/pyquerytracker/db/models.py b/pyquerytracker/db/models.py index 242fa02..a16d293 100644 --- a/pyquerytracker/db/models.py +++ b/pyquerytracker/db/models.py @@ -1,7 +1,8 @@ -from sqlalchemy import Column, Integer, String, Float, DateTime -from sqlalchemy.orm import declarative_base from datetime import datetime +from sqlalchemy import Column, DateTime, Float, Integer, String +from sqlalchemy.orm import declarative_base + Base = declarative_base() diff --git a/pyquerytracker/db/querytracker.db b/pyquerytracker/db/querytracker.db index 6489c56bc87744db6a231fbbba3dabd6a158923a..36427e4691ee1ac0a751dca9af5813dbc19cd11e 100644 GIT binary patch literal 81920 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 diff --git a/pyquerytracker/db/writer.py b/pyquerytracker/db/writer.py index 6b3afd1..171f3d4 100644 --- a/pyquerytracker/db/writer.py +++ b/pyquerytracker/db/writer.py @@ -1,7 +1,8 @@ -from pyquerytracker.db.session import SessionLocal -from pyquerytracker.db.models import TrackedQuery from sqlalchemy.exc import SQLAlchemyError +from pyquerytracker.db.models import TrackedQuery +from pyquerytracker.db.session import SessionLocal + class DBWriter: @staticmethod diff --git a/pyquerytracker/main.py b/pyquerytracker/main.py index dbcf758..6100a6a 100644 --- a/pyquerytracker/main.py +++ b/pyquerytracker/main.py @@ -1,5 +1,6 @@ if __name__ == "__main__": import uvicorn + from pyquerytracker.api import app uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/pyquerytracker/tracker.py b/pyquerytracker/tracker.py index 2ae550f..cb3fe55 100644 --- a/pyquerytracker/tracker.py +++ b/pyquerytracker/tracker.py @@ -1,5 +1,5 @@ -from datetime import datetime, timezone, timedelta -from typing import List, Dict, Any +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List # In-memory store to collect tracked query data query_data_store: List[Dict[str, Any]] = [] diff --git a/tests/exporter/test_json_exporter.py b/tests/exporter/test_json_exporter.py index 1972520..e26fa0e 100644 --- a/tests/exporter/test_json_exporter.py +++ b/tests/exporter/test_json_exporter.py @@ -8,9 +8,11 @@ def run_test_in_subprocess(script: str, export_path: str): print("\n----- Running Script -----\n") print(script) print("\n--------------------------\n") - + try: - subprocess.run(["python3", "-c", script], check=True, capture_output=True, text=True) + subprocess.run( + ["python3", "-c", script], check=True, capture_output=True, text=True + ) except subprocess.CalledProcessError as e: print("STDOUT:\n", e.stdout) print("STDERR:\n", e.stderr) @@ -80,7 +82,7 @@ def bar(): bar() except RuntimeError: pass - + from pyquerytracker.exporter.manager import ExporterManager ExporterManager.get().flush() diff --git a/tests/test_async_core.py b/tests/test_async_core.py index 792ac77..237100c 100644 --- a/tests/test_async_core.py +++ b/tests/test_async_core.py @@ -29,6 +29,7 @@ async def fake_async_db_query(): async def test_async_tracking_output_with_error(caplog): + configure(slow_log_threshold_ms=10, slow_log_level=40) caplog.set_level("ERROR") @TrackQuery() @@ -49,6 +50,7 @@ async def failing_async_query(): async def test_async_tracking_with_class(caplog): + configure(slow_log_threshold_ms=1000, slow_log_level=logging.INFO) caplog.set_level("INFO") class MyAsyncClass: @@ -68,14 +70,15 @@ async def do_work(self, a, b): async def test_async_configure_slow_log(caplog): - try: - configure(slow_log_threshold_ms=50, slow_log_level=logging.ERROR) + configure(slow_log_threshold_ms=10, slow_log_level=40) + caplog.set_level("ERROR", logger="pyquerytracker") - @TrackQuery() - async def do_slow_async_work(): - await asyncio.sleep(0.1) - return "slow" + @TrackQuery() + async def do_slow_async_work(): + await asyncio.sleep(0.1) + return "slow" + try: result = await do_slow_async_work() assert result == "slow" assert len(caplog.records) == 1 diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 121f790..bcbfbab 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1,14 +1,17 @@ from fastapi.testclient import TestClient + from pyquerytracker.api import app from pyquerytracker.core import TrackQuery client = TestClient(app) + # Simulate query activity @TrackQuery() def sample_query(): return "ok" + def test_query_stats_endpoint(): # Trigger a few logs for _ in range(3): diff --git a/tests/test_persist.py b/tests/test_persist.py index af8c526..d9f99ed 100644 --- a/tests/test_persist.py +++ b/tests/test_persist.py @@ -1,8 +1,9 @@ - from pyquerytracker import TrackQuery + @TrackQuery() def sample_query(): return "DB test successful" + sample_query() diff --git a/tests/test_websocket.py b/tests/test_websocket.py index d341433..a8094f4 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,11 +1,10 @@ import asyncio + from starlette.testclient import TestClient + from pyquerytracker.api import app -from pyquerytracker.websocket import ( - connected_clients, - broadcast, - websocket_endpoint -) +from pyquerytracker.websocket import (broadcast, connected_clients, + websocket_endpoint) def test_websocket_connection(): From f90d582518aa731566fe68ce2217e3563549fbda Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Tue, 1 Jul 2025 04:07:53 +0530 Subject: [PATCH 8/9] Fixed Dashboard data issue and updated readme --- README.md | 4 +- pyquerytracker/api.py | 69 +++++++++++++++++++----------- pyquerytracker/db/models.py | 2 +- pyquerytracker/db/querytracker.db | Bin 81920 -> 81920 bytes pyquerytracker/db/writer.py | 37 ++++++++++++++++ pyquerytracker/tracker.py | 6 +-- pyquerytracker/websocket.py | 8 +++- templates/dashboard.html | 13 +----- 8 files changed, 97 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 34f09b9..f678472 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,11 @@ asyncio.run(fetch_data()) To view tracked query logs via REST, WebSocket, or a Web-based dashboard, start the built-in FastAPI server: ```bash -uvicorn pyquerytracker.server:app --reload +uvicorn pyquerytracker.api:app --reload ``` +- ⚠️ If your project or file structure is different, replace `pyquerytracker.api` with your own module path, like `.`. + - Open docs at [http://localhost:8000/docs](http://localhost:8000/docs) - **Query Dashboard UI:** [http://localhost:8000/dashboard](http://localhost:8000/dashboard) - REST endpoint: `GET /queries` diff --git a/pyquerytracker/api.py b/pyquerytracker/api.py index d37c23d..f2ce513 100644 --- a/pyquerytracker/api.py +++ b/pyquerytracker/api.py @@ -1,11 +1,12 @@ -from collections import defaultdict +from datetime import datetime, timedelta from fastapi import FastAPI, Query, Request, WebSocket from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pyquerytracker.config import get_config -from pyquerytracker.tracker import get_tracked_queries # βœ… real-time logs +from pyquerytracker.db.models import TrackedQuery +from pyquerytracker.db.session import SessionLocal from pyquerytracker.websocket import websocket_endpoint app = FastAPI(title="Query Tracker API") @@ -22,29 +23,47 @@ def dashboard(request: Request): @app.get("/api/query-stats") def get_query_stats(minutes: int = Query(5, ge=1, le=1440)): - logs = get_tracked_queries(minutes) - - # You can change this list to match real endpoints you're tracking - all_endpoints = ["GET /users", "POST /items", "GET /items", "DELETE /items"] - durations_by_endpoint = defaultdict(list) - - for log in logs: - # fallback if endpoint is not tracked explicitly - endpoint = log.get("endpoint") or log.get("function_name") or "UNKNOWN" - durations_by_endpoint[endpoint].append(log.get("duration_ms", 0)) - - # Build chart-ready response - return { - "labels": all_endpoints, - "durations": [ - round( - sum(durations_by_endpoint.get(ep, [])) - / max(1, len(durations_by_endpoint.get(ep, []))), - 2, - ) - for ep in all_endpoints - ], - } + cutoff = datetime.utcnow() - timedelta(minutes=minutes) + print(f"[DEBUG] Cutoff: {cutoff}") + + session = SessionLocal() + try: + logs = ( + session.query(TrackedQuery) + .filter(TrackedQuery.timestamp >= cutoff) + .order_by(TrackedQuery.timestamp) + .all() + ) + print(f"[DEBUG] Matching logs: {len(logs)}") + return { + "labels": [q.timestamp.isoformat() for q in logs], + "durations": [q.duration_ms for q in logs], + "events": [q.event for q in logs], + } + finally: + session.close() + + +@app.get("/debug/queries") +def debug_queries(): + session = SessionLocal() + try: + rows = ( + session.query(TrackedQuery) + .order_by(TrackedQuery.timestamp.desc()) + .limit(5) + .all() + ) + return [ + { + "timestamp": r.timestamp, + "duration_ms": r.duration_ms, + "event": r.event, + } + for r in rows + ] + finally: + session.close() @app.websocket("/ws") diff --git a/pyquerytracker/db/models.py b/pyquerytracker/db/models.py index a16d293..069f01a 100644 --- a/pyquerytracker/db/models.py +++ b/pyquerytracker/db/models.py @@ -13,7 +13,7 @@ class TrackedQuery(Base): function_name = Column(String) class_name = Column(String, nullable=True) duration_ms = Column(Float) - timestamp = Column(DateTime, default=datetime.utcnow) + timestamp = Column(DateTime, default=datetime.utcnow()) event = Column(String) # "slow_execution", "normal_execution", "error" func_args = Column(String) func_kwargs = Column(String) diff --git a/pyquerytracker/db/querytracker.db b/pyquerytracker/db/querytracker.db index 36427e4691ee1ac0a751dca9af5813dbc19cd11e..f479593932c3ad1966137a82ea9d0b729156e2bc 100644 GIT binary patch delta 1534 zcmb_cOK1~O6rG92e41o3ohE6mBrjk=rHyai%zHCaHL=xd#fsG`3Po#bqei2lX`@w< zCZM38V0dDLs?;vrYV1aL^#g7Mf2ar|3gXh=#*GW#w4xR>JBK-8!rXiAIVUf-P_P#Y z_VbP2UXJ7H80IpFr9~RwTiG=(5HI^0a!hc-9U&!%zWctTzEEMchZG%{jHt`MNv2#zXYO#Uo;JuIy`Dt+<0M5fxn|4)op<)$6gNhIOg3}I?TK``T ziV=Zcd$}O|{nnXdRojtjU|cTQ`CxfG&M*5F!)%gnU-|A5J`8*}CcYM`u*fk@Rf(e0 zVp|>WE#LI;(Z~s-zM5V+MpPNfn0|Sb3eAcfHv<{6%(r{!-E|S5-Dn@QAV3mvu##wcpyNA3E)rWwIY z&3Mv>_Vyjg<_;usS04KRnJde!2*nWAAtJw(_oNTzGs)r905A#ixzuKx1!w-;SkG+E z9c+J9szO`0JPqWT!Q*9Bn%l-14n3dvifzZY~XLhC=1I#7-Am zkirNj{GdMne7f*m?WF8)7wE|6)1B;_K~rh22@DdY--2!(dLAgHDag3uTKKk=i2Kd2M`0Z3B*%=sh8$ltoy z0{^SZx>2zH4T_$h9u4$pphp8e8tBnLj|O@)(4&DK4fJTBM*}??_+O)eRVv*8@9`u| z8rRmMa)H#V+YTe-G+O}%~Ll(|#Orp%@PL+$G74I31HTX*XEYWC09Y_LxY z&Ym)#{?m<(Yw9;_tX|V(FNUVhpI%-<|F(Wp{o0MG66vQZtJg2zp!myGo8^C9zkdC? z^{M|ZG>vEE8K6B4?a@Gw26{Bmqk$d`^k|?*13en((Lj#|dNk0ZfgTO~|E+;OtsbaC z?^b%K-YdEaN^7(PN=tMIl;+4LC{2+dl*Y(NC=KB=pwx%wL8%LmgHju&C-<5PDAeQV zp+2#F3#E`71Nz6FpB@eLXrM;}JsRlIK#vA`G|;1g9u4$pphp8e8tBnLj|To9(11;s zqf!Z<3ZK$2!Z_g^!FG?W&eq@hlJz2Mu{Fo?oaG#g!~B-{Skqq9XyYly;fB`?XX`)I zU!tF?`%d>~-BR5U?Hk(5wH4X{nx`}?)VHY@sOU;nM!#gg$uXOeD}ujcxy$kM|3MBp12V>BbBx5*4O8=KQTT_T2Kr+_uU^9%svB2RiIkkPrC^n6_Gz3(OAbl$`x)ni_QHIqnVtPt`KXvQ+Pe+a ztLiK3YU0ug4}Av0&<0DQ*XNKF4F?3`nvP|ey7*(KGORBpVTlk%qTcu z2#tkKhhfe>S-oKkkeBF)FI<$X+kaOfT1S@;=Cw~&N9gx(zz%I5MW@cvlr~SHLSyr1 zWaY&j|FNS&oj%Fu6&1rN)>W~3C1+9Wzq8Dxo3-Q$%cU)Q{1-?b-a*SsMy$z8K6CN?q03P(1bzwh`eT(Vn~ zoGyh@pt=Qvje;%Sd({QgLOWZ~dF9TRd>+x~S9VrlP)q=_8PEx?K*@s}wZp z7^Bc9)@uW5s@E6P-!*m@+74H~PjouG$#$>?l?{Yn?*nt-7+Cn$t!rGnvEZb#;|6gg zxII3%+aarj1J-2dkKh1~5t{PF_D+sGSkZk>bjb_$=Sg!BxvqTyw4O{7bzf|L$l;-mJacO-y(&mg=r{_wOTt0Lhhe!0uU~XHtG@Wn_UO&<2^C+!P zVrBmw=C-=l>#LP&(#>sep1Ey0m$*5N1_uK?pstCNToB58@$BQYexK1(AFM-MqSx<1 zJ(luZp17h{atpcQTwbrzU7@SEWXoo5^_8d1YCvo01pDQaPmx5Ybd$M_caY57S~Hm2 zsbl^=Yztanhu`V(xMWj0YkjksTdam5OJ;N1tL5@fI*~g1@;yGUM?x0SaKN0++}4Eh z4^^Q;-9GS)916>FG|X*G8`ZT9Y3A0H#oTV~zkYlnUUiSl>yx_XmBQQ_vzXi3;$?4f zB)dEwFS<+>7*Z`>+0}3FLFbh(-v#5}kN8FbeX6;wdbpNe)K<=OD!}0p#je&dbE`}J zcwzKCe3^yc_4!0EVkJwpsJY$z&2$GoIwX%camR2I2?1zIC-zS|GJ6uN>2RUBz0FRb*XO7qzrQo!K} zxZL?Zk3;n1#+z`Y6@U6=hfEr^+ySQxjJMk%D$N-z$8`UnFI+F!9h0xz1 z{G#NQyQRN-;jIC??Uw4R-6=b<&`FsW-a$7U9F&XJ(dmQrKwd{`Ap;AuExc0~d_AHQ z6)M3Lb$I_P3vXN-4k>NnU65trUH-RTBh!}0u0E4IUWdogwKORf-uYP;-jf#3lhPKw zq-3|`cHs-}ywtU?_zq7CjMbENGbuv!xSdF61?HwLyxT0(xB^@*zo)BpY~h`g`td1e zoxPv)yDZ8eU#eiFl!nlScdcjKk`A;LqQmEP%D4X@Xwq!i!s{@+iPi=7Ig-ooPPQYq z@Xk^!yp^LWcW@+lTq1baGN&l9@RoBHx2eUET^xBX81j;GYY)u|207B^^xk|RT^fWH zosNLRnJ+m#Zns}CfvB{Z9BHM_VTZZWoPIaD@CIjaq!oSmMw(GzA8Xhq{ zwoam9#fxt*`t)GxfJnAh^19thGm}^;f5$$@Bi7V6NO89;5mqeY?QME9Z#c5BV!VUWj9GYSaRzfc_2;uTw;}bF^8F47u8OiLoft&ieOb1B4pzg^)Xe5K zcT3)fd(k?&M9GhNui-#ZHgh}w+5ws>R49lPeaiWLH1;`hZOF{+lq}{pGHJqDX|^Cf z^(BbhNgltmYhEeLZDAI3`)t4A_t|l!c-($G)?QGMYVj6qzXxA8p>p7`dL-F^f;BiW zInCT|E}f4~Oe#Qfd6MBcW^N~?ete2cvzzz3q8Ho+<;P1;M9pp0C(<-*bT}Z|?t#FR zQqZJ{>E^cnE%FXp7e~I=>x4*3S1-lPZ9rjePx^V&&m0MEuhZ+4$4nCD*3U6Q(=?;1 zIPxHp&N)db^C{|8G4-iK3OSNL;CAPWJ`aR%Wy*=ytIVsE^z$6OQj!;*M&gh79r;fPAHyxtX>J$%=o=3(7xKrm78*#4Gj~8y}lgIsC z(dWQMoD&^!n-^5{myftuB_yBI>y|5(I(Sn0h&%J9tzUBLB09l8OS<8)5$90!N9ChS zKS3opAr$U+`#B@7KulE$I?N>4!TDhBxX|S~a6)Q-RF6H6(%xnL5f>mq2(oMDu>QzT z{dmdNui#hl--YqyKqhYK1hhYj`!C9AK?QleaFm63?vuu+_s7arAs44Eu?YLx*-!GvXg@V&l7o`Q;@BMemG)@jEcfMN^VJjpvbJQm~Epee&uww=yt z2aWu?lAdeKLTys4h)ZwU+Js7Qfma11pR*zcPv!YUH=mPgzmwX5_^M9=1C9m_Hgcqu z*Il04K43OUu+FigR&XPHZak8_fg`Q#_Uk94v^8Ct!^H_`tmjDEJbldh9ch*^SvMc- zpOupvhGBTbDY11D4MDVxp3(oVt^tu&KOdY)C?zIV%HI)!a67C0V1MgfDa(1Vi5G%c z^W3N>IgNJv{EifkLP`i?9WQ1uyKeeLoDOmOTwa9A1u86G%M({NBuBPG=;{$TYIESD zB`mGUW^Ywh*S>OnVZwZ+c8lBVcKGC`A<-$^E&Y+%+js}b?Ct6d=C8fvW z$&%OOL-{2I%Tq1htQGA~pwkr2#l3zB+mi(vQq65;&R=PsY~_fg0z5F&)6H#t>c^+u z{@D)h?}Gb??Bh!7P;-0Ed&idUK`Y?`hsG_R#&knin{I9!=huD2sf*+m{V7MrF>_m^ zFt>~HcjmU>ngD8)(2xS3H#4_OGnm^}w{Usi z)B({|KR8SBI+bS#iIwtqnA`YyV2p^7i1;tzncIqYo;;D$Xou+Wx|9B0in%?RXCEhj z&}%nWn$zJ!JEVLuPu#?*v+Z1QlqrSvZ&+HC&D^RDmtE>XyM=~KeDW!h=#*|Ux7hJ@ zZ2y0&VB2l0vh}jAv>s=9$#SM8-~6%pCi5cm0Mk3B9i}4Vr^c~{H(~#OrhbC%eci>{ zPqjO=(=|V8Zi5JbUHzu|a`j2-emPI%)T#cYnn!jEKMJ>k{<`(2JPelvC@H;*=$fFo z7RqtxjLp1H#rkh}p=%=b8bKBv;Vq!UOAkImL!hSf8Q#IF8Im+N#p9VDBtpAt#CH~2&-Z!JSMY)WGeYJH+e=A zucU{POu^!U$BsEj;nCSVr0NTo+@NSWlj?JLC6{t{7HfR>dPr&d=;D5d;Q~ZP@^_?f zS-4O>&RE^OVC%fewNT+Ah2b&;72P!{V(m#xjlz*j?;Y?<2QC^sB&Sj>Ff|IoBU1g8 z(1nGmwllTEfNSD&$-Qon5C{)X^^zLLUzoZ)r%Ql+Mv}L-<6b?4F+RFcaNP6`t7ofHP*ekujLqA^OBHLNz0+wV@XTQNr| z4D@^%2Yku(8|@qsBCrp*q_i-Q^3hvTHJV3KZD~?Nu;p;O;N}Bnu7Y8p=%eDwKYOVn zy?zjd1s1r=k?)4XC}gt^Jsu1LYabOiE4)|M`6=SuP7#fY1;fDMN5###a9|s^c9PSN ziVKejACtjdI`_T){RgJ@X|jftMbG0?CHGZX?uxMMDY9!KeqSv_d1M5MFx(ixFJ9FR zid@WTw#x-4d`ZS?ioXpl=J}Hxj!08MZ^S$)kLVlwS`aT>8_s(X5=M_&Xt3sL%ZWvru_%HFnC zB*JhhBBKf3G)q&IW*Xv40MuP{zyWjDK3-{p;etd)6TFFhFcKH-2HzCFR8tVPrP{z5 zZKPEsJNoXr_bk#a{$u@Tb+Yp;Ps>8=70QXgeMNTdOkFm0w!3K$pqhf z;KRpd`inOhVn;{~6ML865wVCLAEqA=gy9weZh|*#{@Zgc@;;8AP6I2(pClWknqas} zfSb(F3IAEm(V`2M1w8Ci9)`;VxV+%`TT&S^;03~%P8^;Cy#)0FQ}+MW)`zXDtb;5s zTee!9=5Nfmn3tQ!nGTvRHF=F&jH3-v{mX3s|AEe{J*fR7FaUni?AA1CMyo$j|5;tH zw&mQX`d)RDN+2_i&i)^`+IY*SE+}nJ*Cg!!oi1w5BrohP&`nNx7^vI0yiocbn3z8u zm*;_-8K@?~nVFN#naR29?90DM9puS+LPXOk=P)IPcsIp=T=gDDjmzV$NslLlGB`8E zIof}ep%oT^SJwUU1gwgoFtF1zT_qo^UcJeutRyVGaGw^o&uBbhRyJp5?UygvJ5Z@E zm&fT=Vdm7p@lA zDT(-<-hwbt=ab&VoX1yHVbhgX54cL$jqX4g=<-xT>^}L*M(;qL1fb-)lPht|nVFvY z`I-7FK{;s8(q-3yIKQP~V6G!K%4a>PnW^LB}-eGi6< zIpP-HJ!rpDoD^{T^5KH47ugqp*B1;=<%paA)P&t!aX?eyI5R~Yam~ZVw(`XJz%NF3 z!r;tI$>7XvAN|~yiWMo=V4u_PQ0gzS(*F);#$6}68l3fMt9vjEEN*J!l!;tUTXg1q zoMrAHR)i2CuK9i z+FMVl(90)3>l(Po0X3D)l|;vMlL^K;2}1fOW-!4;m)^edGo-@w7!)|NN9;3Xizi@J z426NQp2-9sQ&_pG5UnH}lXw(YLt^oSKsFP6_2rIpaH$g9(H8$ZCOAzWNfQjr`HUub z+49R5a_UYKHzcQ*GM)f5`iv&HqiN3v0;+6>-vf+eY%^C7211kb*>pb(v|-h77( zh!2R*6bQ$u^f41$efqo|TnY3pbl3V3Gr@5hJxvExT~I2#$p1V<_2jb<#!G1!$n=N_ z9{Tu>x(-Ac9=Md`_lsye!I^G?PkLdv4{eGkA8xV$Rj{imaT83*^yz6`mR|8WZj*qx z>;g_cH=Y2*dUT5&eB+l~PMyG_!yy^g$-ywN>rrvj4jDy-xY!9P+;xGIXIva`?NM=a zJ^^;8du2OTF&0{;y*D|J$G{-TM;^1N|D;3`B7)S31_FRDA7V!k zSnQg?shR}hHLl6>z>Uakj)`-CTaR*c%frCK#^ufW$J!9DdSD7XsB;9)%$974{F4@I!#F;5E*@ofDaKNog2hxy9VW6P*iS6)Min^*R9?eB- z0}c^fAQ6*(S=^Zc&biE)srcYKr8%a{3iv~CiG_QaG`xmm-^)MSU&yJG%L{iM(9K*h zyqY6Uc^F1ld!WleQXfeB;^Bw`+BWKa%zC*D>A$!*5mJX>-bXOJk|S4oXrGdRSbn|Gn?Rn zFRWOjFu|Zz4-JX}0|_<3HQ7w?hJ9!Iv1#OiWMFWLuH>l1daHX)aGE}nCKzb&8BOry z%@Z0Frh#e1>je5w(n2XsFmU2Cn&7&!m5;R`BLdXo@OlvDBpA?wFmU6O@r0_c_J4v- zFEAM%m(z{-Oo1@);gcq~@#=e@=1OqD$wJrq5i`L{Q$HU(Wp+F7m)#J|lsm#Ko&eNz z!~~aaU3M2fMqDoNbdZrZDGapqR1;iz)xLq8+Q32s9zj=A;wBh)=rR+$bW0mz#i6?) zg|o*G7y4uqpev-mh+~z?&wRWJtrJ`V1}7fL0R%bkYz@Wx`nc(?n9OBA@6nbd%Ei6TbW%Fg!2b-?kh&LOuAxrXAG%>=v$(Qlh z!7-5{oDiW&-&uBkEYbb60Ta{6p)e_9{p5i=T^{*m`K%Sr=F4DJ3`Kw;o++GQ*z&Zs z9n~sWS2%N$E#=Vn;Xq_aHeY6f>AAGdSV0w+;UF>!r3;_z@nTpT>J zf)2r8q&G+0vYhWaxV3|H%=pH2M5I>+UuOHWRX4QD^cQci55h6LwIdmX%o2XYB99(- zjid-CM1VStn_xU5$FxYVEu|bUrkY^jOyed~miQ~q=!=Vk3m#~vlt+L;jmw+!){fnr zUV;57CuRgD*pkfztG3)2RqW1V^@MG&B;QA6wP;6n-2v;Q@X!cN1kW?UQ^rhtaU439 zAT$hT^fK#~wZhphZYr#ds zEob}~vLFJ~^`r@&b<<*{4UDN4t|kE|OE#*Y!vYatt0zrx-O|=Pbb`Wu*A2JWk|%;O z6Rb)7eCe9^?c866n=JA{4WBQK0BsyG!NU#9&+9;y;f7o8Sbm8~ksO%`Uh+i+r#2F7 zwH5S)2r$HDCV27lcOnseHdIBI!z;>{l`a!ZI96%Sv#;$z>jVj+-4IMew;;iYz!8UD z;Eo++!f^wHONywAX7Z zwf!`&X|B-BSO2QMDd+Dwm#V&2`CMV)CQ?ocT5*-llkkj*- zGczUCnW;av2#F`KaVa{$pe4^%W6lhazLTFXb&l%feIB;o*lml_2=KfSXXY}&6ULMQ zVNpn{@0L#wXvm}pFuYUa35!29>e1RbAXN*bbxbxT9#5F0aAua=ZrsZe0Z(l3pO%Q6 z$gv{I1O+sVNK+m_l5`)ibroZudLI#>M59J&=12D;t23O9c=O>NBP6WA#RWLx=H1=a zu54~xoCxGLTpaL|(b~<~IRy#m#KggV&8_4TKx4sV`ZAcz?Q>2mk*CRlcX()n=>;t1 z=}ltc{2k6rod<5yI~!ys6G+au$y|K(MZ8XN(1eW14Nt1c1jaLNGN)P2C6lNnQL`W+J3|GP4n%;K}|7~3wl8&F}O z8$otI#Rey9g|nGVtcsxsFuF6D%z-~1YdMHk(nT-s%C|ps#0Mf$Hj`O=?C87}R4Uw9 z21iUWF=LJIUXz)okEF>2UU^27xv+EGnVho#)e=pGMXcdf3>)bAzdm30p{`9=qSL_3e_OOCLJq*2HPh7>sC_wq zP<^P{t|}mh*!ll$!jfQQ6Gx*3!zNTI``b%Hr*sG7QZ}CrA6{neqLnM#x#Bz`hrt;E zYBK5~p==^oymoM8g`O`#e{aap-h)d2G#{!}$q8wXkO)4wTr~CfC`e`)UfN5jTUWVx-TGDKTbKvJjuhc7`}ZEY zRpGXL_UG!Y}B2A8nxmJ(ZQ^A>_unNHo30+ai zn9Bv+@1)Cx9?&K}4{&Up|}(UmSs|;KHcS77~D{X zHpK}yLgBbLxm%38Ty+YUYw3#T@a4}5cV_5A&^(t5n9-95+ZA4$*UL%mgDqQ$}kyJFpn>kz(Q;ejm>v1ZpxWZhC!Qr?Rneab7rL z$7%;AxH5wYzM$`8ziyMSNO6%sO;Fm8ZZN?PIJB>GRi|H~hyd{!H^H^dUtP{=HeC9J z(c3kJQlk_=h31&d56=s6#koW$s>SjM5TkKC0kv_*Eo7`C|oGt2sRWTF+vUesE z-2d87T6ZIr1o{^|nWtERU?Bt(OyAqiXo3rJEri$^P=VuCQyXTzHfX0V^O zU0J*MVK@y`V5>9T|K|xm(fxmeZM5}0>s8jJ@CLv$mX+q)&5KMAm;~c)W3}O7!z$qa zzpUS?cj~^;-J)Br8wWW6FV%WATOj{mH0R}1Lcx;r$Cm>yBavr7F;V96aal1LY=m|Sd9AuL7;zu}9;5wrnUDW>^<>ny7# zdfrTV^d#P3aEiE0yWd4vbyd5>T9=HI5e1?(?z&Y^`}k>2v*E!zzcYCVojPQIZH*6^ z8Ih_Mt~eeeDlA3&XLYp>z23W3KC)S7J7JS{Rj3R%GU1k+;O%gI;L zV~b?0?;Y?a=#l5l%;sugRSZReJf6wbGCz5L!2v|8aQzXo^?H!qYd8??lg-ugetuDt z+^3*T_dwETC(uz8GwEo!T50;|a zY7^d3m$_O|U|Y)wrYC<8-hnm<&ZuC?SG<&yaJ8aFjun~v+T=ElKv<=qX^OE=eWNJQ zo>8MT{nUSTD8)GfPGEoo9~9ZOFmXV2M#Yt#HnWo}4nhg2jSEJB# zJ_^=YFqzuevWY&pc|p_F5W|vrFtG;H6h=pdU|R2V*EJ|iCQz$!letXsFbIU8;1vSs z6dy2~IVKZ$*0{+m9{ro0qeY)2!7xU(C`EI!nN0OZZC}gJ>f+U;_qpYf(X5;OBQu#X zr7BFODwD}Pyr^g-+V>uqijE_$a5j^PRWTF=?sz7Xd8}k;*o9Wo0e7yjCk4P{(xWiAXZnqhT`B^wDK9BS2TrXfkL2I`c)vQV!Dr4x%8bz9VT-6ecqQ z)b)(fi521F1}RKtOti=CM4k_Y<*y)eW~$8!oxT}a;$t%C6N<>DJ`g!0)nwLP^d#aU z$0Rr)sGi(a#iA3Zr+&Vy;>knk>;~;PTuv-2S83!l)MQROd()c&GDhJ13|0vTV*4jW zwxpZP>Ql8LPHo&^GNoi+#iJ9=3X{2bO(RbPyq^x6Oj!&uVKO6|IaZ{6?BCisb%JGz zb1j_i|Hla1DF5F8`TzS^{$kl+8EHP#Ji+w7>0)^IZ-=qmXfgc75H=JWRQlWXb99&K z3bog2KGj^QDODd<&rz##?#-#o>8pB1HH7RVTgeF^?udWDno)`}+%iM0*>-pxBHfy~ z;AAQwL5g=5Tq#m|1BpfWchDtZ_Xit&+4Ip+;3DJJtfu|*4rq3&#ZA-^st8@$U%cH2X6!7)^S8MQ=02G>vu4QWi5s$ z%poT#43t~0v13tpHxxDb^} z^CgSQ4P&hF)!q6WeA>L~a&x1=#m?yFp0LMwe+yoBxSE^tfTqIDjY?VE+}X3pgbrLZ zoS)-asS2XNl}?6c78p*KyScIHCFR4DOhA^Exn;2A1)@NSPDY06^ZqR-k0d3)n}x}g z8L`Mv8uPjIoliw{wSdnvmXPxErBPr$BRrVJd&8gLdWJ;1aBWp7X;Ktu(0x*pi7xzj zT|1{XkO|lAOnU4wH#Z6-XnADFybmNGeFB$;;K4Tqy)NPAMu7#58oBag26k`+df>4i z9ve40E`wFN#NPCXV%+!wE_$DmMwt~$YGN(<9qy$Qo*k>Mt8eh9EO@~v5Uz2n)NtL2 z@*o|IDpp7E27ytDS|uP{<5np&=RBofln@65J>?3*+)H3w2^PK)|qkk-vyO*LwnFf%r4#F5mqgUYatgE^fLY`^8GK$wL)g9aFl+9*!bV2|t zCCr`bUXJ8xamm$F4)VlNra$VWmq$nNY<~)2lUGDOucgK#D3|-P8f&q zbf^as;Q>wXsPu}v4w62=bGhkrWf-ql>g@Nvq--{;qXa}@e{$JM?G-yui)D}?fhP{$ zFZQ5rHE3~Y1}&afxALPEX{#cHKG^}Y0}ddLjvw3rtG=feqTMND+XxN&hhe{9``Pxr z?U3y=+b6aUYzJ(w+Fr6fYwNH*Y`f2Pr)`(*M%y*ED{O7H^KGrRt+r;{dfRH?3sl(_ z+veM5*~)B1wi9h0n`j$n8)+M28))loGuyN_!TO{1TkBWWgVv9LS@5QHzx4&{)7HnW zd#v|ZZ@2!*+74`k%dHo~or8$=4C^LqlXa!F&brK6VV!FYTBlhHtpTgcns3ds4z~`n z_P6F*jaIegSIc3`zb#)_{$c5~ykmJC-YeK^dCKyrsETGm=tSZXXwEDJ3m%M44gWwOO*ku2jaqb+vJv6j9Tt3_`S%!kc~%m>Y#<^$&a z=Dp?)^B(hV^Dc9{d55{pyv^KVZZFzqqzHtjOCn|7GmOxsK?re;%k<4&xr+UFUU%vfmj z8AW5B(QX`Q%r(L>h2gN_kl~=A({RAB->}!vVc28XZP;aKH|#L98MYZ(49$in!wN%{ zp~4U{lo<*QK7(k;GuRCS4Y>xrLC_!8AJQMxcj^!5_v`oSJM??>yY;*D?fM=1HvKkz zi@sUkq+g-0(pTt1`Z9f?-lrG!d3w8kpgvcx*9*GCxU=s;m#4Gq2I_KkdYzy>tUUybl1}Xb?SAcE zZHIP`cDHtywq3hJ+os*7ZP7Mso3tymRoV(|NL!{Y)cUldHcxBU4%Fsq_3#qKVa*}U zK~1OTfM&mDFT6{!2i~UKrD@mf(6ni`X<9VR@KWUpO_ioX6VjAv3N=2BsL9jVH3K!d z8ofq9)eQ@5y_)lKRZ>MC`GI;1XB7pi?~ zQJtr@s|Tuc)q1s%b2#Tv&cU3{oC7)gbN1$Re8li?!rvHn3Y`o; z6h375f$#yt_l5Tvz9+oL@Ll0uhVKaPFnn8ho8bZB0K>P0w-~-Dyvgtl;SGkb3$HVL zO?ZvrtHP@c|0?{I;eKI1!&ih?8157HF??Bgnc+*qOAKEWUS#-!@B+iY z5#bSr4+{@7d`NhR;T~ZR!v}>289pF9!0>+Ieuno6_c6RzxR>EQ!aWS{7Vc)aTiDI; zF5xbQcM5khyhFHy;qAih3~v)|V|c4@E5loaTNv&Vb}{_3@Mng968^++r?8XZ&BDzL zZxU`|c%yJ5!yAMf7`6-T46hfiXLy}(9m8vdYZ+c6T*L5c;cA9g30E=vqwq(DJA@q! zuN1Cic!h8U!#@arV0gK3Im63@%NSlNT*~kg;Sz>zLL0;F!ghui3l}rINVtgMg~Ejl zFAy$Zc)oBx!}Emm7;Y1`F+5i|m*F|WISkJh&Suyuv@(neQHBvA!Z0j^8MX*5njAvT zI*XQDx6<;=GiiCo8MHk8bXuNv8ZEbMp=EP3EjMqb<)%%vJoQvsZrn)A4I5~=emyNu zIfa%@O|)FMj+SfJ(sIojTCQGA%T=prxpE~f8yji4Vg)UiFQ;Wg11;<8X<1iC%i3C6 z*3{6lx|)_%RkW^8178cU7pn#T>C)0A$BwC($A}uFQq-7vLOTV9%J|8W;URruQ zv~;^^>2lH1>7=D3(bD0dr6|(!gcE3)pHIsP6KFYpJS~quo|far(Q@oqTIS`^a?BW7 zjvh_RQKM)%awIKBjG*Q4;j|n!jFxsgEr$-J<&Yt?96Xqog9g#^xZ`Mf?6I^w<``NI z97xLn18CX5KP~(9qh;T|wCvM|mc4t^vR5x!=H}AUW}~ImN=u7{mS!_8O(t3zjkGiv zXsOrJQm3P(R!d8bhL&nIEpu{csZ!`xa|6?Cw`st6*;*vX;Tl-e(nIUvIY#gk8PfD!`upkmVrk=nq)- z!+yTQvIlnayDaUnmv6IdgPnY{r3v=&RhA0a#g|zMVGl1_@?Zx)(30D;fB)aPe?KY? zRFBXe4P?*&^%qoAR8trhstOqvs0tWPR!wF&Ni~V#iK-JBPE<`~7*GWm`c;00K9!H5 zSLJ2sQF$1;Rc?kZm5ZTMd57WK*RHYuaVan zzDizY_*e2*hWp8WhOdxU815tc7`{wiX800$iQ$XnMTRes7Z^TIo@cn1>}B{Id5+<; z7xEW|Pm!k>K1rTr*g-lNK0%&f_&9l-;bY`6hL4g*89qWDVfZk4 znBha@A%=U%9)=H+2N^y<9$|}T|xtZZjPp)Tp9l4I-wd7ic*N|%%UQMoMcon&d;UCE#8SWrE7+y)PWOxO+g5e*? z9~fRvE@yZdxs2hZ^#T=acgpo=47O zxQ%RMcrH1Y;W^|ShG&zr8McyEhEWn_7$FgcVG?H8LRuJ}Mb2Wlm272rCOMPg8RQIx zr<2neo<>e%xP@$C*i4!kZYG-7 zq?%zBsbW}3Dj6;#%NQ;tOBpU9OBkL^PG-26EM~ZfEMizeDj1$bPGY!_EM&NVEMPdF z%x5@{%wss0%w;%-%wZTJA%?TbY=*PQEQaNzoMDgz8O|g#8O|Ux7)~eC8J3YUhNYyG z;WRRhVF@W=SWJo;P9;+r7Lg)`Q^*vCg`|*S0V!ZOnM`IliA-X6A~})aL^6?KfCL!& ziJzg5_!xSLm!XGv7`lm@p^LZ}I*F5^L?ng|;$SEek>LsC1cvz}pWy^Df#G;Ep5gK2 zc!uN1IEG`%ScZ8dkKq_HhT&*3n&Buiis48ylHmw4g5hv7oZ&DsjG>*_84e{w84e*s z7!D?b84eHCV|Xk%mfzFdxqZ$-!c4F_?F=}!Z!^6E&Q9|zl47=JR}@q__gpg!>@#|7=9^y$?yx|3x=Ny zpELYS_>AE{g?}*5x|ItEK=KKFu#$JY%hT~ZNzcIR(bXLgySFbf{W~qNt?^Mso zxj1K{>hG%a$;I&Q-)$f)<)9}w#KkC*H1k;DqWU#T_`Z4y+E9%$R+Z#99 z8#mb5zud54U2S9a#`-$@=EjXH?A7qkSD#uRZjP=nGwSC6NP)&GrObgg{pz-F6xw68 zf~N#UXZ*J2(I9-f8)}_WgVXD!1d;^dv%A_}5ru?c8HuC*@2+{S1?>)arOV?_c88KU z3Yo$(-j6CeySPeD0CX{(!fTi&gd?WHHOwfAxjaN;#t8QUP-9kM$oUGt1O}eUIN(Tp`o6=1x>nDvw4F`rwJVYIr_W)`KT!>A|^+0!0cn zGmSVr@HSVb8Mru04^DY&^FoejAT;8auY*y@V1`?C<<)R@nGRCixanX+DVM=$1xMVp zAu~D^9tT{Tq2eI@JFj+-P7JNxOy&E)UA2?o&2*GJ9Uc)~n1K*_(HZNWkl(&Z)DZ58 zC~x(20|i~If!mFWi&2o1jB>oeD;h>GC^W8JUU|x?_3O73RKER4Ge?VX>EG>28sJn~ zEhH$T;wJR!B{Wp8s;{i8iHnTG`L=|&N$B)qx`FXUp0OJH@Loo#h?e>Y}LAu1K}NWxH8 zIC4j0bxxaC2|u|a3VGl%`bQ^izwK-J5=zGzr2FwnaQi2NeTAHG8SU%B*;jthfvUyr z@xg+sPzxBtf+#$@mb@4>_pPSA=yZkZNw7d6?oc2)GxcIr-5s75t^^0%;_qr;>|)f6 z)X$eZw6l=+%W#nayUtV^osM3NYHes+)`BX-DT!X}O5UVsS^CAOMWd&NIJKeaKYhvD zIIK-&(Ne`Oc=6zeJ5dpklE&%ud*oRnOZWf7g)0O& z@!w=~S%0wJZ>RNdAsJ=noe~|epWB<+AzNDXk>H{(}!1CI) zGAjE|o4Nl+jT`f?J8`8zQW;2og)RNz+*VDN*x1nC)}dAM8oL7$8q8v4Kbv5ZLM|ZWoBAPAvRLNBjJgw;ESi>Rd zMap~GiUAbOmV9dIdHZGvNKb=K!6)auhZu$AQ|RWqWb0Sd&rqBmxW#;kpTPrT*n$>} zLMAFy+({2Ds^E(AfH#4QgWOc8xVg{XrrgxRQ3R_U-Na$yAT)4VY&(Z0KRUP9qG#Ii z20I~mq4euw!}E7#zlB`x4u|}C#VBOK!b1Za+uL8|Xc5xRfHT`Qn^QvrkQ58w%Fo|1 zaRpBt96#`Kra)#aj=XnI263ngX=Wy$@`6A0IHc&NY@q?w-pS85<8mPoq}WO%I;NXK z1F=q$Ki3#NH8z&e(7=>uUhDlVT46{z?p7pm%Ua=Vp#ijtxzUYTj6?50)$BrLHC>SJ z3u@^*8pa{IA**p%{kMXJEoiMEwV+2jirz(<8WRzP?lTVXCRj%IwB){Rzai5Hy2D8$ z=^5Tlh{F3|8MhbHXI`_LBO2mBUNqxDL9{6~G!XjFtR`%B)9N|kEL2N?Rc*5|UHsgmI65i|ZLuOTZf?vIq&NVp1z4N9vQ3Q)s~j zt?~hys12No1B0QcJtb_X-;5iDVKS`aT5v>^$C|!EqI|pTxBwj<9BpzPa{dc*jqNl+H zskZA_}hX*w|5G%fG^;k%Bs@tBb6hQ8zmWV(5pHq^l*X5MN?`biPIC`9KHus0^qaY)%4 zvF|btN8!ksCr_B4K1~yKhgV*KN7?0lDW_@wEYmb}bNiC?k=Z30oD>(z!VR_d%hH4U zPUu96?If!%n|MML3ji6T0^byT2Dqp zd0f?l$vs!Xce2k*zrm(!zRCf#-Q4!5%Z;E|w= z^Up!<`lSQaha332-b`lFnx+5h58bngqZD{?8%dH6qlE7NO{PAAt;qVdHE6lQGR^$G z`OlEg?ru{J{B_Syj|O@)(4&DK4fJTBM*}??=+Qur2L4~yK(KWRClFtJ^Q*bgdgWP= zD<95rAn1YZkbM`u^BhLgV^V+RkWQhE^E(qVQG%8gr>#$K}doi|b=m47I|$N}0kH z)=B5>YeFjtZyR_e= cutoff) + .order_by(TrackedQuery.timestamp.desc()) + ) + results = session.execute(stmt).scalars().all() + + return [ + { + "function_name": row.function_name, + "class_name": row.class_name, + "duration_ms": row.duration_ms, + "timestamp": row.timestamp, + "event": row.event, + "error": row.error, + "func_args": row.func_args, + "func_kwargs": row.func_kwargs, + } + for row in results + ] + + except SQLAlchemyError as e: + print(f"DB fetch error: {e}") + return [] + finally: + session.close() diff --git a/pyquerytracker/tracker.py b/pyquerytracker/tracker.py index cb3fe55..3c66f1c 100644 --- a/pyquerytracker/tracker.py +++ b/pyquerytracker/tracker.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from typing import Any, Dict, List # In-memory store to collect tracked query data @@ -7,11 +7,11 @@ def store_tracked_query(log: Dict[str, Any]): """Store a single tracked query log entry.""" - log["timestamp"] = datetime.now(timezone.utc) + log["timestamp"] = datetime.utcnow() query_data_store.append(log) def get_tracked_queries(minutes: int) -> List[Dict[str, Any]]: """Return all tracked queries within the last `minutes`.""" - cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes) + cutoff = datetime.utcnow() - timedelta(minutes=minutes) return [log for log in query_data_store if log["timestamp"] >= cutoff] diff --git a/pyquerytracker/websocket.py b/pyquerytracker/websocket.py index 75d6f89..2ed993b 100644 --- a/pyquerytracker/websocket.py +++ b/pyquerytracker/websocket.py @@ -1,7 +1,10 @@ +import asyncio from typing import List from fastapi import WebSocket, WebSocketDisconnect +from pyquerytracker.db.writer import DBWriter + connected_clients: List[WebSocket] = [] @@ -9,7 +12,10 @@ async def websocket_endpoint(websocket: WebSocket): await websocket.accept() connected_clients.append(websocket) try: - await websocket.receive_text() + while True: + await asyncio.sleep(2) # every 2 seconds + recent_logs = DBWriter.fetch_all(minutes=5) # or a custom method + await websocket.send_json(recent_logs) except WebSocketDisconnect: pass finally: diff --git a/templates/dashboard.html b/templates/dashboard.html index 52d7ee8..e485cf7 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -418,29 +418,20 @@

Query Dashboard

const totalQueries = data.durations.length; const avgDuration = Math.round(data.durations.reduce((a, b) => a + b, 0) / totalQueries); const errorCount = data.events.filter(e => e === 'error').length; - // const successRate = Math.round(((totalQueries - errorCount) / totalQueries) * 100); document.getElementById('totalQueries').textContent = totalQueries; document.getElementById('avgDuration').textContent = `${avgDuration}ms`; - document.getElementById('successRate').textContent = `${successRate}%`; document.getElementById('errorCount').textContent = errorCount; - - // Update success rate color - const successElement = document.getElementById('successRate'); - successElement.className = successRate >= 95 ? 'stat-value success-indicator' : - successRate >= 80 ? 'stat-value warning-indicator' : - 'stat-value error-indicator'; + } else { // Show zero state document.getElementById('totalQueries').textContent = '0'; document.getElementById('avgDuration').textContent = '0ms'; - document.getElementById('successRate').textContent = '100%'; document.getElementById('errorCount').textContent = '0'; - - document.getElementById('successRate').className = 'stat-value success-indicator'; } } + showEmptyState() { this.updateChart({}); this.updateStats({}); From fc6b1c2df70b2968623530c13da31f9022bfe852 Mon Sep 17 00:00:00 2001 From: Vamsi AKisetti Date: Tue, 1 Jul 2025 13:42:58 +0530 Subject: [PATCH 9/9] Optimized Dashboard UX --- Makefile | 2 +- pyquerytracker/api.py | 5 ++-- pyquerytracker/db/querytracker.db | Bin 81920 -> 81920 bytes templates/dashboard.html | 38 +++++++++++++++++++++++------- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 1f17fb8..b592a75 100644 --- a/Makefile +++ b/Makefile @@ -8,4 +8,4 @@ lint: # Run linters flake8 . pylint pyquerytracker/ - @echo "Linting complete." \ No newline at end of file + @echo "Linting complete." \ No newline at end of file diff --git a/pyquerytracker/api.py b/pyquerytracker/api.py index f2ce513..1862728 100644 --- a/pyquerytracker/api.py +++ b/pyquerytracker/api.py @@ -30,15 +30,16 @@ def get_query_stats(minutes: int = Query(5, ge=1, le=1440)): try: logs = ( session.query(TrackedQuery) - .filter(TrackedQuery.timestamp >= cutoff) + .filter(TrackedQuery.timestamp >= cutoff) # Now comparing text to text .order_by(TrackedQuery.timestamp) .all() ) print(f"[DEBUG] Matching logs: {len(logs)}") return { - "labels": [q.timestamp.isoformat() for q in logs], + "labels": [q.timestamp for q in logs], "durations": [q.duration_ms for q in logs], "events": [q.event for q in logs], + "function_names": [q.function_name for q in logs], } finally: session.close() diff --git a/pyquerytracker/db/querytracker.db b/pyquerytracker/db/querytracker.db index f479593932c3ad1966137a82ea9d0b729156e2bc..a013f4a21819edda1dc74e4b648465e8eac87c6e 100644 GIT binary patch delta 3082 zcmaKuTWl0n7{_O~?4?V4P8Vo*ySwd9>3wOpXRc>vd)by+A=ttes#YH8bt|={OSi3+ z7~HNHKx0%K5)7RA_2cD?GmLm)O^AOndOAEZClPQ3A7Q4|cm}?8U z^vftXt~uC-7)-)(I0F0OUf2V7!tHP?Y=SaGFa#@LDfGe(Fb~=x1O5iL!A)=#Tmip= zpTGrh4x9yFfX~1g@F6$_=E1ArBzOTFUzoIllr6_%iAD)qS_qq)37eV-8yg8D5kgfZ zR1`vt31yj3k_bhSP!I@to{-}R5h848APk2I>+1>Y>Ik=Nv8D7sp%A&KttG6fA*`+@ ztg0fctR$?cAS^E@+`O5vtc;Ll34=kx(o({b62d@$(C;Vo`3Q@P3B6uIkB6|R$d;oW zZAjaIK4iFk%v zSC$gyJd-{#S!wMIX{}xl?v3@nq33zTOZ5oXBaTHl!t)UchdG25#7!iU2L{Ib;!nhf zW~N6I6E(F@9`39qS!MU7wx>+9B9;*?n=Y@>UiA9eXZx@gmmFd3%OANgN#H zhvN!rsbl#%RvltZ~&N>_SXEU~m1x;CU!j%xSl0Tp zW&bhC7)VwWRiq90_U&F8LtI5G#*hw{uV?&zM_X-Lax=q;zS%@_tm{zw_`uXuYig?t zSJhC60r8<^F`lj@<+R;F;ao3L7z&yQxEFunNHnDB$(8LhCyp?-`+HZj% zJ1G5GKs(TKm=iJ2iCVCvFjeYepnisG-EL+e8Ute|O*W9z4~{6TU2UXfsepb63kRe9 zmLEq=3Lq7$GS2i&4s0~wO0Bs2{kQDfO~af-oAoxoiwXYMzR}yr%*0S@#lgd_>$K5J zVFl}(wqhBndrxw+&&6z9dYC+e1Ig$^7vF!6+Kd#I$bnpbgp4I^FV^E5^*I*1nUO?d zbYeQ%_oiisNd-X`5HgOAwotm!;xz;md)@w+$@e)0@d8~y9+F-B+|%KFyBp2(M1h6Q zb_Sk-!_WtQ0WX7YP?-Niek#8*Z+~9V`f1mfu7j?!+)KIj&dbgd&h~{#x6|4gHCkxD z(Q#u{3rd7T5s3?P7$Ypx9^lTFrEF8+KGUp-MV^{%CRt_A=}?ntmX{@}bkgNbhCiP< zx#OK%S&vDkXIVv}YWQ<-NENu%{%kb!XWz{;t2h5%O??Q0pk$8m@o3`qla5G+FduYvT*~&{($GG9ygU5auI`W9R3n_u>Ur!hB}e)(BHDgt@h+|8$I=*E`^3Lzw(ZVeWc&40O8FOCym@+w0 z)nC4Xs3^;YmW3J47UsjzPfyXB(Q9B;Mf_@E)~^xfUUzwmNd+0{-j+!hhA`_44t6n7qf}!s{jB1 delta 101 zcmZo@U~On%ogmE{&A`AQ0>mspEI3iaoH2T1!UBGFULaSTebHog2mOtOIqXb6;*&2r xh;QEQ?#jW=1(M^hn9Q!AzgbYgiGOm1em|=KP>^*qkHHuI%>n@*<^u_V1ORRP7@hzC diff --git a/templates/dashboard.html b/templates/dashboard.html index e485cf7..1fe1d7f 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -298,7 +298,7 @@

Query Dashboard

} const hasData = data && Array.isArray(data.labels) && data.labels.length > 0; - const safeData = hasData ? data : { labels: [], durations: [], events: [] }; + const safeData = hasData ? data : { labels: [], durations: [], events: [], function_names: [] }; const chartData = this.prepareChartData(safeData, hasData); this.chartInstance = new Chart(ctx, { @@ -348,11 +348,26 @@

Query Dashboard

borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, callbacks: { - label: function(context) { + title(context) { + if (!context.length) return 'No data'; + + const index = context[0].dataIndex; + const rawDate = new Date(safeData.labels[index]); + + const formattedDateTime = rawDate.getFullYear() + + '-' + String(rawDate.getMonth() + 1).padStart(2, '0') + + '-' + String(rawDate.getDate()).padStart(2, '0') + + ' ' + String(rawDate.getHours()).padStart(2, '0') + + ':' + String(rawDate.getMinutes()).padStart(2, '0') + + ':' + String(rawDate.getSeconds()).padStart(2, '0'); + + const functionName = safeData.function_names[index]; + return `${formattedDateTime} β€” ${functionName}`; + }, + label(context) { if (!hasData) return 'No queries executed'; - const event = safeData.events[context.dataIndex]; - const duration = context.parsed.y; - return `Duration: ${duration}ms (${event})`; + const duration = context.parsed.y.toFixed(2); + return `Duration: ${duration} ms`; } } } @@ -439,12 +454,19 @@

Query Dashboard

initializeChart() { - // Initialize with empty state immediately + // Set the time window dropdown to 60 minutes + const defaultWindow = 60; + this.currentTimeWindow = defaultWindow; + document.getElementById("timeWindow").value = defaultWindow; + + // Clear old data and load initial chart + this.clearError(); this.showEmptyState(); - // Then try to load real data this.loadChart(); - } + // User selection will take over on next interaction + } + showLoading(show) { const loading = document.getElementById('loadingIndicator'); loading.style.display = show ? 'block' : 'none';