Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9d18364
add pagination to requests
Mar 17, 2026
8099135
show count of all requests inside the db instead of just the request …
Mar 17, 2026
27e4a68
add filter to get_requests query based on selected tab
Mar 17, 2026
c999a09
fix support for custom dashboard path
Mar 17, 2026
e7cd122
add disconnected state to ui and add health endpoint
Mar 17, 2026
57e9baa
fix transaction in transaction bug
Mar 17, 2026
78de839
add support for logging
Mar 17, 2026
1526d57
add missing translations
Mar 17, 2026
714f4a5
add time range filtering for logs
Mar 17, 2026
1040f9d
avoid dialect specific date function
Mar 17, 2026
6b5f47e
add tests for log_handler add endpoints to test_api_endpoints_accessible
Mar 17, 2026
062b984
add missing custom storage engine documenation, add capturing logs se…
Mar 17, 2026
48f1666
run new ui build
Mar 17, 2026
e025031
bump version to 0.4.0, add support for openapi_prefix parameter
Mar 17, 2026
e786146
exclude dashboard path with prefix from tracking
Mar 17, 2026
044ce4a
extract and reuse refresh interval selection component in all components
Mar 18, 2026
6b217eb
include logs to be cleared in clear endpoint
Mar 18, 2026
7a09ac0
add drag support to barchart to select custom timerange
Mar 18, 2026
4417f37
rebuild ui
Mar 18, 2026
2b36235
extract table pagination, improve barchart mouse handling, delete mor…
Mar 18, 2026
e8ba217
rebuild ui dist
Mar 18, 2026
b6d7929
make requests page a bit more compact
Mar 19, 2026
5b19160
correctly set default style for timerange buttons
Mar 23, 2026
877728f
truncate path/url to first 500 characters
Mar 23, 2026
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,6 @@ dmypy.json
# Pyre
.pyre/

!fastapi_radar/dashboard/dist/
!fastapi_radar/dashboard/dist/
radar.duckdb.wal
radar.duckdb
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Access your dashboard at: **http://localhost:8000/\_\_radar/**
radar = Radar(
app,
db_engine=engine, # Optional: SQLAlchemy engine for SQL query monitoring
storage_engine=None, # Optional: custom SQLAlchemy engine for Radar's own storage
dashboard_path="/__radar", # Custom dashboard path (default: "/__radar")
max_requests=1000, # Max requests to store (default: 1000)
retention_hours=24, # Data retention period (default: 24)
Expand Down Expand Up @@ -201,6 +202,47 @@ radar = Radar(app, db_path="./data")

If the specified path cannot be created, FastAPI Radar will fallback to using the current directory with a warning.

### Custom Storage Engine

For full control over where Radar's monitoring data is stored, pass any SQLAlchemy-compatible engine via the `storage_engine` parameter. This lets you store Radar data in PostgreSQL, MySQL, SQLite, or any other supported database instead of the default DuckDB file.

```python
from fastapi import FastAPI
from fastapi_radar import Radar
from sqlalchemy import create_engine

app = FastAPI()

# Store Radar data in PostgreSQL
radar_storage = create_engine("postgresql+psycopg2://user:password@localhost/radar_db")
radar = Radar(app, storage_engine=radar_storage)
radar.create_tables()
```

```python
# Store Radar data in MySQL
radar_storage = create_engine("mysql+pymysql://user:password@localhost/radar_db")
radar = Radar(app, storage_engine=radar_storage)
radar.create_tables()
```

```python
# Async engine (e.g. asyncpg) is supported too
from sqlalchemy.ext.asyncio import create_async_engine

radar_storage = create_async_engine("postgresql+asyncpg://user:password@localhost/radar_db")
radar = Radar(app, storage_engine=radar_storage)
radar.create_tables()
```

You can also set the `RADAR_STORAGE_URL` environment variable to any SQLAlchemy URL and Radar will use it automatically without any code changes:

```bash
export RADAR_STORAGE_URL="postgresql+psycopg2://user:password@localhost/radar_db"
```

The `storage_engine` parameter takes precedence over `RADAR_STORAGE_URL`, which in turn takes precedence over `db_path`.

### Development Mode with Auto-Reload

When running your FastAPI application with `fastapi dev` (which uses auto-reload), FastAPI Radar automatically switches to an in-memory database to avoid file locking issues. This means:
Expand All @@ -218,6 +260,43 @@ radar.create_tables() # Safe to call - handles multiple processes gracefully

This behavior only applies when using the development server with auto-reload (`fastapi dev`). In production or when using `fastapi run`, the standard file-based DuckDB storage is used.

## Capturing Logs

FastAPI Radar can capture Python log records and display them in the dashboard. Call `attach_logger()` after creating the `Radar` instance to register a logging handler.

### Attach to the root logger (captures everything)

```python
import logging
from fastapi import FastAPI
from fastapi_radar import Radar

app = FastAPI()
radar = Radar(app)
radar.create_tables()

# Capture all log records at DEBUG level and above
radar.attach_logger()
```

### Attach to a specific logger

```python
import logging
from fastapi import FastAPI
from fastapi_radar import Radar

app = FastAPI()
radar = Radar(app)
radar.create_tables()

# Only capture records from your application's logger
app_logger = logging.getLogger("myapp")
radar.attach_logger(logger=app_logger, level=logging.WARNING)
```

`attach_logger()` returns the `logging.Handler` instance in case you need to remove it later. The `level` parameter filters which records are stored (default: `logging.DEBUG`).

## What Gets Captured?

- ✅ HTTP requests and responses
Expand All @@ -227,6 +306,7 @@ This behavior only applies when using the development server with auto-reload (`
- ✅ Slow query detection
- ✅ Exceptions with stack traces
- ✅ Request/response bodies and headers
- ✅ Python log records (via `attach_logger()`)

## Contributing

Expand Down
47 changes: 41 additions & 6 deletions example_app.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
"""Example FastAPI application with Radar integration."""

from typing import List, Optional
from datetime import datetime
from fastapi import FastAPI, Depends, HTTPException, Query
from typing import List, Optional

from fastapi import Depends, FastAPI, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Boolean
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, create_engine

try:
from sqlalchemy.orm import declarative_base
except ImportError:
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session

from fastapi_radar import Radar, track_background_task
import logging

from fastapi import BackgroundTasks
from sqlalchemy.orm import Session, sessionmaker

from fastapi_radar import Radar, track_background_task

# Database setup
engine = create_engine("sqlite:///./example.db", connect_args={"check_same_thread": False})
Expand All @@ -22,6 +26,8 @@

# Models

logger = logging.getLogger(__name__)


class Product(Base):
__tablename__ = "products"
Expand Down Expand Up @@ -120,6 +126,7 @@ class Config:
theme="auto",
# auth_dependency=verify_radar_credentials, # Uncomment to enable authentication
)
radar.attach_logger(logger, level=logging.INFO) # Capture INFO and above logs
radar.create_tables()

# Dependency
Expand All @@ -139,6 +146,7 @@ def get_db():
@app.get("/")
async def root():
"""Root endpoint."""
logger.info("Root endpoint called")
return {
"message": "Welcome to the Example API",
"dashboard": "Visit /__radar to see the debugging dashboard",
Expand All @@ -153,6 +161,7 @@ async def list_products(
db: Session = Depends(get_db),
):
"""List all products with pagination."""
logger.info("Listing products: skip=%d, limit=%d, in_stock_only=%s", skip, limit, in_stock_only)
query = db.query(Product)

if in_stock_only:
Expand All @@ -165,9 +174,11 @@ async def list_products(
@app.get("/products/{product_id}", response_model=ProductResponse)
async def get_product(product_id: int, db: Session = Depends(get_db)):
"""Get a specific product by ID."""
logger.info("Fetching product id=%d", product_id)
product = db.query(Product).filter(Product.id == product_id).first()

if not product:
logger.warning("Product id=%d not found", product_id)
raise HTTPException(status_code=404, detail="Product not found")

return product
Expand All @@ -176,39 +187,47 @@ async def get_product(product_id: int, db: Session = Depends(get_db)):
@app.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(product: ProductCreate, db: Session = Depends(get_db)):
"""Create a new product."""
logger.info("Creating product: name=%s", product.name)
db_product = Product(**product.dict())
db.add(db_product)
db.commit()
db.refresh(db_product)
logger.info("Created product id=%d", db_product.id)
return db_product


@app.put("/products/{product_id}", response_model=ProductResponse)
async def update_product(product_id: int, product: ProductCreate, db: Session = Depends(get_db)):
"""Update an existing product."""
logger.info("Updating product id=%d", product_id)
db_product = db.query(Product).filter(Product.id == product_id).first()

if not db_product:
logger.warning("Product id=%d not found for update", product_id)
raise HTTPException(status_code=404, detail="Product not found")

for key, value in product.dict().items():
setattr(db_product, key, value)

db.commit()
db.refresh(db_product)
logger.info("Updated product id=%d", product_id)
return db_product


@app.delete("/products/{product_id}")
async def delete_product(product_id: int, db: Session = Depends(get_db)):
"""Delete a product."""
logger.info("Deleting product id=%d", product_id)
db_product = db.query(Product).filter(Product.id == product_id).first()

if not db_product:
logger.warning("Product id=%d not found for deletion", product_id)
raise HTTPException(status_code=404, detail="Product not found")

db.delete(db_product)
db.commit()
logger.info("Deleted product id=%d", product_id)
return {"message": "Product deleted successfully"}


Expand All @@ -219,16 +238,19 @@ async def list_users(
db: Session = Depends(get_db),
):
"""List all users with pagination."""
logger.info("Listing users: skip=%d, limit=%d", skip, limit)
users = db.query(User).offset(skip).limit(limit).all()
return users


@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db: Session = Depends(get_db)):
"""Get a specific user by ID."""
logger.info("Fetching user id=%d", user_id)
user = db.query(User).filter(User.id == user_id).first()

if not user:
logger.warning("User id=%d not found", user_id)
raise HTTPException(status_code=404, detail="User not found")

return user
Expand All @@ -237,12 +259,15 @@ async def get_user(user_id: int, db: Session = Depends(get_db)):
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate, db: Session = Depends(get_db)):
"""Create a new user."""
logger.info("Creating user: username=%s", user.username)
# Check for existing user
existing_user = (
db.query(User).filter((User.username == user.username) | (User.email == user.email)).first()
)

if existing_user:
logger.warning("User creation failed: username=%s or email=%s already exists",
user.username, user.email)
raise HTTPException(
status_code=400, detail="User with this username or email already exists"
)
Expand All @@ -251,12 +276,14 @@ async def create_user(user: UserCreate, db: Session = Depends(get_db)):
db.add(db_user)
db.commit()
db.refresh(db_user)
logger.info("Created user id=%d", db_user.id)
return db_user


@app.get("/slow-query")
async def slow_query_example(db: Session = Depends(get_db)):
"""Example endpoint that performs a slow query."""
logger.info("Slow query endpoint called")
# This query will be highlighted as slow in Radar
import time

Expand All @@ -277,6 +304,7 @@ async def slow_query_example(db: Session = Depends(get_db)):
@app.get("/error")
async def trigger_error():
"""Example endpoint that raises an exception."""
logger.error("Error endpoint called")
# This will be captured in the Exceptions tab
raise ValueError("This is an example error for demonstration purposes")

Expand Down Expand Up @@ -329,13 +357,15 @@ async def failing_task():
@app.post("/send-email")
async def send_email(email: str, subject: str, background_tasks: BackgroundTasks):
"""Example endpoint that triggers a background task."""
logger.info("Scheduling send_email_task for email=%s", email)
background_tasks.add_task(send_email_task, email, subject)
return {"message": "Email will be sent in the background"}


@app.post("/process-report/{user_id}")
async def process_user_report(user_id: int, background_tasks: BackgroundTasks):
"""Example endpoint that triggers a long-running background task."""
logger.info("Scheduling process_report for user_id=%d", user_id)
background_tasks.add_task(process_report, user_id)
return {"message": "Report processing started"}

Expand All @@ -345,34 +375,39 @@ async def generate_analytics_endpoint(
background_tasks: BackgroundTasks, days: int = Query(7, ge=1, le=365)
):
"""Generate analytics for the specified number of days."""
logger.info("Scheduling generate_analytics for days=%d", days)
background_tasks.add_task(generate_analytics, days)
return {"message": f"Analytics generation started for last {days} days"}


@app.post("/sync-inventory")
async def sync_inventory(background_tasks: BackgroundTasks):
"""Synchronize inventory (sync task example)."""
logger.info("Scheduling sync_inventory_task")
background_tasks.add_task(sync_inventory_task)
return {"message": "Inventory sync started"}


@app.post("/test-failure")
async def test_task_failure(background_tasks: BackgroundTasks):
"""Test a failing background task."""
logger.info("Scheduling failing_task")
background_tasks.add_task(failing_task)
return {"message": "Failing task started (check background tasks page)"}


@app.get("/health")
async def health_check():
"""Health check endpoint (excluded from Radar by default)."""
logger.debug("Health check called")
return {"status": "healthy"}


if __name__ == "__main__":
import uvicorn
from pathlib import Path

import uvicorn

# Check if dashboard is built
dashboard_dist = Path(__file__).parent / "fastapi_radar" / "dashboard" / "dist"
if not dashboard_dist.exists():
Expand Down
5 changes: 3 additions & 2 deletions fastapi_radar/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""FastAPI Radar - Debugging dashboard for FastAPI applications."""

from .background import track_background_task
from .log_handler import RadarLoggingHandler
from .radar import Radar

__version__ = "0.3.4"
__all__ = ["Radar", "track_background_task"]
__version__ = "0.4.0"
__all__ = ["Radar", "RadarLoggingHandler", "track_background_task"]
Loading