Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
680 changes: 513 additions & 167 deletions devtrack_sdk/cli.py

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions devtrack_sdk/controller/devtrack_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ async def stats(
status_code: Optional[int] = Query(None, description="Filter by status code"),
):
"""Get DevTrack statistics and logs from DuckDB."""
db = get_db()
db = get_db(read_only=True)

try:
# Get summary stats
Expand Down Expand Up @@ -62,7 +62,7 @@ async def delete_logs(
),
):
"""Delete logs from the database with various filtering options."""
db = get_db()
db = get_db(read_only=False)

try:
deleted_count = 0
Expand Down Expand Up @@ -105,7 +105,7 @@ async def delete_logs(
@router.delete("/__devtrack__/logs/{log_id}", include_in_schema=False)
async def delete_log_by_id(log_id: int):
"""Delete a specific log by its ID."""
db = get_db()
db = get_db(read_only=False)

try:
deleted_count = db.delete_logs_by_id(log_id)
Expand All @@ -131,7 +131,7 @@ async def metrics_traffic(
hours: int = Query(24, description="Number of hours to look back"),
):
"""Get traffic metrics over time."""
db = get_db()
db = get_db(read_only=True)
try:
traffic_data = db.get_traffic_over_time(hours=hours)
return {"traffic": traffic_data}
Expand All @@ -144,7 +144,7 @@ async def metrics_errors(
hours: int = Query(24, description="Number of hours to look back"),
):
"""Get error trends and top failing routes."""
db = get_db()
db = get_db(read_only=True)
try:
error_data = db.get_error_trends(hours=hours)
return error_data
Expand All @@ -157,7 +157,7 @@ async def metrics_perf(
hours: int = Query(24, description="Number of hours to look back"),
):
"""Get performance metrics (p50/p95/p99 latency)."""
db = get_db()
db = get_db(read_only=True)
try:
perf_data = db.get_performance_metrics(hours=hours)
return perf_data
Expand All @@ -170,7 +170,7 @@ async def consumers(
hours: int = Query(24, description="Number of hours to look back"),
):
"""Get consumer segmentation data."""
db = get_db()
db = get_db(read_only=True)
try:
segments_data = db.get_consumer_segments(hours=hours)
return segments_data
Expand Down
87 changes: 73 additions & 14 deletions devtrack_sdk/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,24 @@ def _validate_int(value: Any, name: str = "value", min_value: int = 0) -> int:
raise
raise ValueError(f"{name} must be a valid integer") from e

def __init__(self, db_path: str = "devtrack_logs.db"):
def __init__(self, db_path: str = "devtrack_logs.db", read_only: bool = True):
"""Initialize the database connection and create tables if they don't exist."""
self.db_path = db_path
self._lock = threading.Lock()
# Create initial connection for table creation
self._init_conn = duckdb.connect(db_path)
self._create_tables()
self._init_conn.close()
self.read_only = read_only
# Create initial connection for table creation (only if not read-only)
if not read_only:
self._init_conn = duckdb.connect(db_path)
self._create_tables()
self._init_conn.close()

@property
def conn(self):
"""Get thread-local database connection."""
if not hasattr(_thread_local, "connection") or _thread_local.connection is None:
_thread_local.connection = duckdb.connect(self.db_path)
_thread_local.connection = duckdb.connect(
self.db_path, read_only=self.read_only
)
else:
# Check if connection is closed and reconnect if needed
try:
Expand All @@ -50,7 +54,9 @@ def conn(self):
_thread_local.connection.close()
except Exception:
pass
_thread_local.connection = duckdb.connect(self.db_path)
_thread_local.connection = duckdb.connect(
self.db_path, read_only=self.read_only
)
return _thread_local.connection

def _create_tables(self):
Expand Down Expand Up @@ -239,6 +245,15 @@ def get_logs_count(self) -> int:
result = self.conn.execute("SELECT COUNT(*) FROM request_logs").fetchone()
return result[0]

def tables_exist(self) -> bool:
"""Check if database tables exist (read-only check)."""
try:
# Try to query the table - will fail if it doesn't exist
self.conn.execute("SELECT 1 FROM request_logs LIMIT 1")
return True
except Exception:
return False

def get_logs_by_path(
self, path_pattern: str, limit: Optional[int] = None
) -> List[Dict[str, Any]]:
Expand Down Expand Up @@ -409,6 +424,19 @@ def delete_all_logs(self) -> int:

return count_before

def reset_sequence(self) -> None:
"""Reset the sequence to start from 1."""
try:
# Try to reset the sequence
self.conn.execute("ALTER SEQUENCE seq_log_id RESTART WITH 1")
except Exception:
# If sequence doesn't exist or can't be reset, try to recreate it
try:
self.conn.execute("DROP SEQUENCE IF EXISTS seq_log_id")
self.conn.execute("CREATE SEQUENCE seq_log_id START 1")
except Exception:
pass # Ignore if sequence operations fail

def delete_logs_by_path(self, path_pattern: str) -> int:
"""Delete logs filtered by path pattern."""
# Get count before deletion
Expand Down Expand Up @@ -888,30 +916,61 @@ def get_client_traffic_over_time(

def close(self):
"""Close the database connection."""
self.conn.close()
if (
hasattr(_thread_local, "connection")
and _thread_local.connection is not None
):
try:
_thread_local.connection.close()
except Exception:
pass
_thread_local.connection = None

def __del__(self):
"""Ensure connection is closed when object is destroyed."""
if hasattr(self, "conn"):
self.close()
# Don't access self.conn property here as it may try to create a new connection
# Instead, directly check and close thread-local connection
if (
hasattr(_thread_local, "connection")
and _thread_local.connection is not None
):
try:
_thread_local.connection.close()
except Exception:
pass
_thread_local.connection = None


# Global database instance
_db_instance: Optional[DevTrackDB] = None


def get_db() -> DevTrackDB:
def get_db(read_only: bool = True) -> DevTrackDB:
"""Get the global database instance."""
global _db_instance
# If instance exists but has different read_only setting, recreate it
# BUT: If we have an existing instance with write access, we can use it
# for reads too (DuckDB allows read operations on write connections)
if _db_instance is not None:
if _db_instance.read_only != read_only:
# If existing instance is write mode and we need read, we can use it
if not _db_instance.read_only and read_only:
# Use existing write connection for read operations
# (allowed by DuckDB)
return _db_instance
# If existing instance is read-only and we need write, recreate
elif _db_instance.read_only and not read_only:
_db_instance.close()
_db_instance = None
if _db_instance is None:
_db_instance = DevTrackDB()
_db_instance = DevTrackDB(read_only=read_only)
return _db_instance


def init_db(db_path: str = "devtrack_logs.db"):
def init_db(db_path: str = "devtrack_logs.db", read_only: bool = True):
"""Initialize the database with a custom path."""
global _db_instance
if _db_instance:
_db_instance.close()
_db_instance = DevTrackDB(db_path)
_db_instance = DevTrackDB(db_path, read_only=read_only)
return _db_instance
18 changes: 13 additions & 5 deletions devtrack_sdk/django_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,20 @@ def __init__(
if exclude_path:
self.skip_paths.extend(exclude_path)

# Initialize database if not already done
if DevTrackDjangoMiddleware._db_instance is None:
db_path = db_path or getattr(
settings, "DEVTRACK_DB_PATH", "devtrack_logs.db"
# Initialize database if not already done or if db_path is provided
# (db_path provided means we want to use a specific database)
final_db_path = db_path or getattr(
settings, "DEVTRACK_DB_PATH", "devtrack_logs.db"
)
if DevTrackDjangoMiddleware._db_instance is None or (
db_path and DevTrackDjangoMiddleware._db_instance.db_path != db_path
):
# Close existing instance if switching databases
if DevTrackDjangoMiddleware._db_instance is not None:
DevTrackDjangoMiddleware._db_instance.close()
DevTrackDjangoMiddleware._db_instance = DevTrackDB(
final_db_path, read_only=False
)
DevTrackDjangoMiddleware._db_instance = DevTrackDB(db_path)

super().__init__(get_response)

Expand Down
2 changes: 1 addition & 1 deletion devtrack_sdk/django_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def get_db_instance() -> DevTrackDB:
"""Get the database instance from middleware"""
if DevTrackDjangoMiddleware._db_instance is None:
db_path = getattr(settings, "DEVTRACK_DB_PATH", "devtrack_logs.db")
DevTrackDjangoMiddleware._db_instance = DevTrackDB(db_path)
DevTrackDjangoMiddleware._db_instance = DevTrackDB(db_path, read_only=False)
return DevTrackDjangoMiddleware._db_instance


Expand Down
2 changes: 1 addition & 1 deletion devtrack_sdk/management/commands/devtrack_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def handle(self, *args, **options):
return

try:
db = DevTrackDB(db_path)
db = DevTrackDB(db_path, read_only=False)
deleted_count = db.delete_all_logs()

self.stdout.write(
Expand Down
2 changes: 1 addition & 1 deletion devtrack_sdk/management/commands/devtrack_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def handle(self, *args, **options):
output_format = options["format"]

try:
db = DevTrackDB(db_path)
db = DevTrackDB(db_path, read_only=True)
stats = db.get_stats_summary()
recent_logs = db.get_all_logs(limit=limit)

Expand Down
2 changes: 1 addition & 1 deletion devtrack_sdk/middleware/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async def receive() -> Message:

try:
log_data = await extract_devtrack_log_data(request, response, start_time)
db = self.db_instance if self.db_instance else get_db()
db = self.db_instance if self.db_instance else get_db(read_only=False)
db.insert_log(log_data)
except Exception as e:
print(f"[DevTrackMiddleware] Logging error: {e}")
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ devtrack_sdk = [
devtrack = "devtrack_sdk.cli:app"

[tool.isort]
profile = "black"
profile = "black"

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
norecursedirs = [".git", ".venv", "venv", "__pycache__", "*.egg"]
38 changes: 38 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
Pytest configuration for DevTrack SDK tests
"""

from unittest.mock import patch

import pytest
import requests

# Ignore test_wsgi.py during pytest collection
# It's a WSGI configuration file, not a test file
collect_ignore = ["test_wsgi.py"]


@pytest.fixture(autouse=True)
def mock_network_requests():
"""
Automatically mock network requests to prevent hanging in CI.
This ensures detect_devtrack_endpoint() doesn't make real network calls.
Tests that need specific network behavior should override these mocks.
"""
# Mock requests.get/delete to raise RequestException by default
# This makes detect_devtrack_endpoint() try all URLs (fast with 0.5s timeout)
# and then prompt. We mock typer.prompt/confirm to avoid hanging on user input.
# Tests that need specific behavior will override these mocks.
with patch(
"requests.get", side_effect=requests.RequestException("Mocked network error")
):
with patch(
"requests.delete",
side_effect=requests.RequestException("Mocked network error"),
):
# Mock typer prompts with sensible defaults to avoid hanging
# Tests that test prompt behavior will override these
with patch("typer.prompt", return_value="localhost"):
with patch("typer.confirm", return_value=False):
with patch("typer.echo"): # Suppress echo output in tests
yield
Loading