Skip to content
18 changes: 18 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Contributor Covenant Code of Conduct

## Our Pledge

In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual orientation.

## Our Standards

Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members

## Our Responsibilities

Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Contributing to SQLens

Thank you for your interest in SQLens! We welcome contributions from the community.

## How to Contribute

1. **Fork the repository.**
2. **Create a branch** for your feature or bug fix: `git checkout -b my-new-feature`
3. **Make your changes.** Ensure code follows standard Go formatting (`go fmt`).
4. **Run tests**: `make test`
5. **Commit your changes**: `git commit -m "feat: my new feature"`
6. **Push to your branch** and open a **Pull Request**.

## Guidelines
- All new features must include unit tests.
- Keep Pull Requests focused on a single change.
- Follow the [Code of Conduct](CODE_OF_CONDUCT.md).
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@

SQLens is a transparent TCP proxy that intercepts and analyzes SQL queries in real-time. It helps detect **N+1 query patterns**, **slow queries**, and provides a live performance dashboard—**with zero code changes to your application.**

## Demo

### Dashboard Overview
![SQLens Dashboard](assets/dashboard.png)


## Features

- **Transparent Proxy:** Just point your app to SQLens instead of your DB.
- **N+1 Detection:** Automatically flags inefficient ORM patterns.
- **Performance Guardrails:** Real-time detection of SQL anti-patterns (e.g., SELECT *, missing WHERE).
- **Data Privacy:** Optional PII redaction mode to mask sensitive query values.
- **Slow Query Tracking:** Real-time latency measurement and visualization.
- **Live Dashboard:** Web-based interface to see what's happening under the hood.

Expand Down Expand Up @@ -44,6 +52,7 @@ SQLens can be configured via environment variables:
- `SQLENS_LISTEN_ADDR`: Proxy listen address (default `:5433`)
- `SQLENS_TARGET_ADDR`: Target database address (default `localhost:5432`)
- `SQLENS_N1_THRESHOLD`: Number of repeated queries to trigger alert (default `5`)
- `SQLENS_REDACT_SENSITIVE`: Enable to mask sensitive data in logs/dashboard (default `false`)

---
Built for SQL performance observability.
14 changes: 14 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Security Policy

## Reporting a Vulnerability

We take the security of SQLens seriously. If you believe you have found a security vulnerability, please report it to us by following these steps:

1. open a GitHub issue.
2. Include as much detail as possible: steps to reproduce, impact, and a proof of concept if available.

We will acknowledge your report within 48 hours and keep you updated on our progress.

## Redact Mode

SQLens includes a `RedactSensitive` mode (set via `SQLENS_REDACT_SENSITIVE=true`). When enabled, the proxy will mask all literals in SQL queries before they reach the dashboard or logs. We recommend enabling this in production environments where queries may contain PII.
5 changes: 3 additions & 2 deletions analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type QueryEvent struct {
Timestamp time.Time
Fingerprint string
N1Flag bool
Violations []string // New: Performance/Safety guardrail warnings
}

type AnalysisResult struct {
Expand All @@ -30,16 +31,16 @@ func NewPipeline(analyzers ...Analyzer) *Pipeline {
return &Pipeline{analyzers: analyzers}
}

func (p *Pipeline) Process(ctx context.Context, event QueryEvent) {
func (p *Pipeline) Process(ctx context.Context, event QueryEvent) QueryEvent {
currentEvent := event
for _, a := range p.analyzers {
result, err := a.Analyze(ctx, currentEvent)
if err != nil {
// In a real app we'd log this, but we continue processing
continue
}
if result != nil {
currentEvent = result.Event
}
}
return currentEvent
}
34 changes: 34 additions & 0 deletions analyzer/guardrail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package analyzer

import (
"context"
"strings"
)

type GuardrailAnalyzer struct{}

func NewGuardrailAnalyzer() *GuardrailAnalyzer {
return &GuardrailAnalyzer{}
}

func (g *GuardrailAnalyzer) Analyze(ctx context.Context, event QueryEvent) (*AnalysisResult, error) {
upperQuery := strings.ToUpper(event.RawQuery)

// Guardrail 1: DELETE/UPDATE without WHERE (Dangerous!)
if (strings.Contains(upperQuery, "DELETE") || strings.Contains(upperQuery, "UPDATE")) &&
!strings.Contains(upperQuery, "WHERE") {
event.Violations = append(event.Violations, "DANGEROUS: Modification query without WHERE clause detected!")
}

// Guardrail 2: SELECT * (Performance Antipattern)
if strings.Contains(upperQuery, "SELECT *") {
event.Violations = append(event.Violations, "SLOW: SELECT * usage (fetch only needed columns)")
}

// Guardrail 3: SELECT without LIMIT (Scale Risk)
if strings.Contains(upperQuery, "SELECT") && !strings.Contains(upperQuery, "LIMIT") {
event.Violations = append(event.Violations, "UNBOUNDED: SELECT without LIMIT (potential large result set)")
}

return &AnalysisResult{Event: event}, nil
}
Binary file added assets/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 17 additions & 7 deletions benchmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ func main() {
}
defer db.Close()

fmt.Println("🚀 Starting Realistic SQLens Benchmark...")
fmt.Println("Starting Realistic SQLens Benchmark...")

// 1. Setup Table and Seed Data
fmt.Println("\n--- 🛠️ Setup: Creating 'users' table ---")
fmt.Println("\n--- Setup: Creating 'users' table ---")
_, err = db.Exec(`
DROP TABLE IF EXISTS users;
CREATE TABLE users (
Expand All @@ -47,7 +47,7 @@ func main() {

// 2. Realistic N+1 Simulation
// Scenario: Fetching user IDs, then fetching full details for each one individually
fmt.Println("\n--- 🕵️ Scenario: N+1 Detection (Fetching user details one by one) ---")
fmt.Println("\n--- Scenario: N+1 Detection (Fetching user details one by one) ---")
rows, err := db.Query("SELECT id FROM users LIMIT 10")
if err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -75,7 +75,7 @@ func main() {
}

// 3. Slow Query Simulation
fmt.Println("\n--- 🐢 Scenario: Slow Query Detection (Complex search) ---")
fmt.Println("\n--- Scenario: Slow Query Detection (Complex search) ---")
start := time.Now()
var count int
// Using pg_sleep to force a slow query report in SQLens
Expand All @@ -86,12 +86,22 @@ func main() {
fmt.Printf("Slow query finished (latency: %v)\n", time.Since(start))

// 4. Batch Updates
fmt.Println("\n--- Scenario: Batch Updates ---")
fmt.Println("\n--- Scenario: Batch Updates ---")
for i := 0; i < 50; i++ {
_, _ = db.Exec("UPDATE users SET created_at = NOW() WHERE id = $1", (i%20)+1)
}
fmt.Println("Finished 50 quick updates.")

fmt.Println("\n✅ Benchmark Complete!")
// 5. Performance Guardrails Test
fmt.Println("\n--- Scenario: Performance Guardrails ---")
fmt.Println("Triggering 'SELECT *' warning...")
_, _ = db.Exec("SELECT * FROM users")

fmt.Println("Triggering 'Missing WHERE' warning...")
_, _ = db.Exec("UPDATE users SET name = 'Ghost'")

fmt.Println("Triggering 'Missing LIMIT' warning...")
_, _ = db.Exec("SELECT name FROM users")

fmt.Println("\nBenchmark Complete!")
fmt.Println("Check the SQLens Dashboard (http://localhost:8080) for N+1 alerts and latency maps.")
}
3 changes: 2 additions & 1 deletion cmd/sqlens/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ func main() {
time.Duration(cfg.N1WindowSecs)*time.Second,
cfg.N1Threshold,
),
analyzer.NewGuardrailAnalyzer(), // New Feature: Real-time SQL Guardrails
)

// Initialize TCP Proxy
proxyServer := proxy.NewServer(cfg.ListenAddr, cfg.TargetAddr, pipeline, memStore)
proxyServer := proxy.NewServer(cfg.ListenAddr, cfg.TargetAddr, pipeline, memStore, cfg.RedactSensitive)

// Initialize Web Dashboard
webServer := web.NewServer(":8080", memStore)
Expand Down
30 changes: 20 additions & 10 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,33 @@ import (
)

type Config struct {
ListenAddr string
TargetAddr string
SlowQueryMs int
N1WindowSecs int
N1Threshold int
ListenAddr string
TargetAddr string
SlowQueryMs int
N1WindowSecs int
N1Threshold int
RedactSensitive bool
}

func LoadConfig() Config {
return Config{
ListenAddr: getEnv("SQLENS_LISTEN_ADDR", ":5433"),
TargetAddr: getEnv("SQLENS_TARGET_ADDR", "localhost:5432"),
SlowQueryMs: getEnvAsInt("SQLENS_SLOW_QUERY_MS", 100),
N1WindowSecs: getEnvAsInt("SQLENS_N1_WINDOW_SECS", 10),
N1Threshold: getEnvAsInt("SQLENS_N1_THRESHOLD", 5),
ListenAddr: getEnv("SQLENS_LISTEN_ADDR", ":5433"),
TargetAddr: getEnv("SQLENS_TARGET_ADDR", "localhost:5432"),
SlowQueryMs: getEnvAsInt("SQLENS_SLOW_QUERY_MS", 100),
N1WindowSecs: getEnvAsInt("SQLENS_N1_WINDOW_SECS", 10),
N1Threshold: getEnvAsInt("SQLENS_N1_THRESHOLD", 5),
RedactSensitive: getEnvAsBool("SQLENS_REDACT_SENSITIVE", false),
}
}

func getEnvAsBool(key string, fallback bool) bool {
strValue := getEnv(key, "")
if value, err := strconv.ParseBool(strValue); err == nil {
return value
}
return fallback
}

func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
Expand Down
30 changes: 20 additions & 10 deletions proxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,25 @@ import (
)

type Server struct {
listenAddr string
targetAddr string
pipeline *analyzer.Pipeline
store EventStore
listenAddr string
targetAddr string
pipeline *analyzer.Pipeline
store EventStore
redactSensitive bool
}

// EventStore represents an interface to save and retrieve query events
type EventStore interface {
Save(event analyzer.QueryEvent)
}

func NewServer(listen, target string, p *analyzer.Pipeline, s EventStore) *Server {
func NewServer(listen, target string, p *analyzer.Pipeline, s EventStore, redact bool) *Server {
return &Server{
listenAddr: listen,
targetAddr: target,
pipeline: p,
store: s,
listenAddr: listen,
targetAddr: target,
pipeline: p,
store: s,
redactSensitive: redact,
}
}

Expand Down Expand Up @@ -152,7 +154,15 @@ func (s *Server) handleConnection(ctx context.Context, clientConn net.Conn) {
Timestamp: time.Now(),
Latency: l,
}
s.pipeline.Process(context.Background(), event)

// Reassign event to the one that's been through the pipeline
event = s.pipeline.Process(context.Background(), event)

// If redaction is enabled, mask the raw SQL
if s.redactSensitive {
event.RawQuery = event.Fingerprint
}

s.store.Save(event)
}(query, latency)
} else {
Expand Down
21 changes: 14 additions & 7 deletions web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,30 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
<title>SQLens Dashboard</title>
<style>
body { font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 20px; }
.query { background: #2d2d2d; margin-bottom: 10px; padding: 10px; border-radius: 4px; }
.n1 { border-left: 4px solid #f44336; }
.query { background: #2d2d2d; margin-bottom: 10px; padding: 10px; border-radius: 4px; border-left: 4px solid transparent; }
.n1 { border-left-color: #f44336; }
.violation { border-left-color: #ffeb3b; }
.latency { color: #4caf50; }
.violation-text { color: #ffeb3b; font-weight: bold; }
</style>
<script>
async function fetchQueries() {
const res = await fetch('/api/queries');
const queries = await res.json();
const container = document.getElementById('queries');
container.innerHTML = queries.map(q =>
'<div class="query ' + (q.N1Flag ? 'n1' : '') + '">' +
container.innerHTML = queries.map(q => {
let classes = 'query';
if (q.N1Flag) classes += ' n1';
if (q.Violations && q.Violations.length > 0) classes += ' violation';

return '<div class="' + classes + '">' +
'<strong>[' + new Date(q.Timestamp).toLocaleTimeString() + ']</strong> ' +
'<span class="latency">' + (q.Latency / 1000000) + 'ms</span><br>' +
'<span class="latency">' + (q.Latency / 1000000).toFixed(2) + 'ms</span><br>' +
'<code>' + q.RawQuery + '</code>' +
(q.N1Flag ? '<br><strong style="color:#f44336">N+1 Detected!</strong>' : '') +
'</div>'
).join('');
(q.Violations ? q.Violations.map(v => '<br><span class="violation-text">' + v + '</span>').join('') : '') +
'</div>';
}).join('');
}
setInterval(fetchQueries, 1000);
window.onload = fetchQueries;
Expand Down
Loading