Database Monitoring & Observability

Detect issues before users do: metrics, logs, and alerts

You Can't Fix What You Can't Measure

Production databases fail in unpredictable ways: queries slow down, connections max out, disk fills up. Monitoring transforms invisible problems into visible metrics, enabling proactive fixes before outages occur. This lesson covers the essential metrics, tools, and alerting strategies for production database operations.

Real-World Impact:
  • GitHub (2018): Automated failover promoted a behind replica during a network partition; better lag monitoring in the failover logic could have prevented a 24hr outage
  • AWS RDS: Provides dozens of CloudWatch metrics plus Performance Insights and Enhanced Monitoring; Amazon knows monitoring prevents outages
  • GitLab: Lost 6 hours of production data, lacked monitoring to detect replication failure

Essential Database Metrics

The four golden signals of database monitoring: latency, traffic, errors, and saturation. Monitor these metrics to detect problems before they impact users.

1. Query Performance Metrics
MetricDescriptionHealthy RangeAlert Threshold
Average Query TimeMean execution time across all queries<10ms>50ms (degraded), >100ms (critical)
P95 Query Time95th percentile (slow query threshold)<50ms>200ms (degraded), >500ms (critical)
P99 Query Time99th percentile (worst case latency)<100ms>500ms (degraded), >1000ms (critical)
Queries Per SecondTotal query throughputApplication-specificSudden drop >30% or spike >200%
Slow Query CountQueries exceeding slow query threshold<1% of total>5% (degraded), >10% (critical)
2. Connection Metrics
MetricDescriptionHealthy RangeAlert Threshold
Active ConnectionsCurrently executing queries<50% of max>70% (degraded), >90% (critical)
Idle ConnectionsConnected but not executing<20% of max>50% (connection leak suspected)
Connection Wait TimeTime waiting for available connection0ms (no wait)>10ms (pool exhausted)
Failed ConnectionsConnection attempts that failed0 per minute>5/min (degraded), >20/min (critical)
3. Cache & Buffer Metrics
MetricDescriptionHealthy RangeAlert Threshold
Cache Hit Ratio% of reads served from cache>99%<95% (degraded), <90% (critical)
Buffer Pool Hit Rate% of pages found in shared buffers>99%<95% (increase shared_buffers)
Index Hit Rate% of index lookups satisfied by cache>99%<95% (missing indexes or cold cache)
4. Lock & Contention Metrics
MetricDescriptionHealthy RangeAlert Threshold
DeadlocksTransactions aborted due to deadlock0 per hour>1/hour (degraded), >10/hour (critical)
Lock Wait TimeTime transactions spend waiting for locks<10ms>100ms (contention), >1000ms (critical)
Long-Running QueriesQueries running >60 seconds0>5 queries (likely holding locks)
5. Resource Utilization
MetricDescriptionHealthy RangeAlert Threshold
CPU UtilizationProcessor usage by database<70%>80% (degraded), >95% (critical)
Memory UsageRAM consumed by database<80%>90% (degraded), >95% (critical)
Disk I/O Wait% time CPU waiting for disk<10%>30% (slow disk), >50% (critical)
Disk SpaceAvailable storage>30% free<20% (warning), <10% (critical)
Replication LagSeconds replica is behind master<1 second>5s (degraded), >30s (critical)

Query Performance Monitoring

Identify slow queries in real-time using PostgreSQL's statistics collector and pg_stat_statements extension.

Enable pg_stat_statements Extension
-- 1. Add to postgresql.conf
shared_preload_libraries = 'pg_stat_statements, auto_explain'

-- 2. Restart PostgreSQL
sudo systemctl restart postgresql

-- 3. Create extension in your database
CREATE EXTENSION pg_stat_statements;

-- Result: PostgreSQL now tracks query statistics (execution time, call count, etc.)
Result: pg_stat_statements tracks aggregate statistics for all queries. Minimal overhead (<5%), essential for production monitoring.
Finding Slowest Queries
-- Top 10 slowest queries by total execution time
SELECT
    query,
    calls,
    total_exec_time,
    mean_exec_time,
    max_exec_time,
    stddev_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- Example output:
-- query                                    | calls | total_exec_time | mean_exec_time | max_exec_time
-- -----------------------------------------|-------|-----------------|----------------|---------------
-- SELECT * FROM orders WHERE user_id = $1 | 50000 | 125000ms   | 2.5ms     | 500ms
-- UPDATE products SET stock = stock - $1  | 25000 | 75000ms    | 3.0ms     | 1200ms
-- SELECT COUNT(*) FROM users              | 1000  | 50000ms    | 50ms      | 150ms
Result: Top 3 queries consume 250 seconds of total database time. Optimizing these queries (add indexes, rewrite) yields maximum impact.
Python Script: Monitor Query Performance
import psycopg2
import time

def monitor_slow_queries(threshold_ms=100):
    """Continuously monitor and alert on slow queries."""
    conn = psycopg2.connect("dbname=myapp user=postgres")
    cur = conn.cursor()

    while True:
        cur.execute("""
            SELECT
                pid,
                now() - query_start AS duration,
                state,
                query
            FROM pg_stat_activity
            WHERE state != 'idle'
              AND (now() - query_start) > interval '%s milliseconds'
            ORDER BY duration DESC
        """ % threshold_ms)

        slow_queries = cur.fetchall()

        if slow_queries:
            print(f"⚠️  Found {len(slow_queries)} slow queries:")
            for pid, duration, state, query in slow_queries:
                print(f"  PID {pid}: {duration} - {query[:100]}...")
        else:
            print("✓ All queries under threshold")

        time.sleep(5)  # Check every 5 seconds
Result: Real-time monitoring of active queries. Alerts when queries exceed 100ms threshold. Can be extended to kill long-running queries or send notifications.

Slow Query Logs & Analysis

Slow query logs capture queries exceeding a time threshold. Essential for identifying performance regressions after deployments.

Configure PostgreSQL Slow Query Logging
-- Edit postgresql.conf

# Log queries slower than 100ms
log_min_duration_statement = 100

# Include query execution plan for slow queries
auto_explain.log_min_duration = 100
auto_explain.log_analyze = true

# Log destination
log_destination = 'csvlog'
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'

# Restart PostgreSQL
sudo systemctl restart postgresql

# Result: Slow queries written to log files in CSV format
Result: Every query taking >100ms is logged with execution plan. CSV format enables easy parsing and analysis with scripts or log aggregators.
Analyzing Slow Query Logs with Python
import csv
import re
from collections import defaultdict

def analyze_slow_query_log(log_file):
    """Parse and analyze PostgreSQL slow query log."""
    query_stats = defaultdict(lambda: {
        'count': 0,
        'total_time': 0,
        'max_time': 0
    })

    with open(log_file, 'r') as f:
        reader = csv.DictReader(f)
        for row in reader:
            if row.get('message', '').startswith('duration:'):
                # Extract duration
                duration_match = re.search(r'duration: ([d.]+) ms', row['message'])
                if not duration_match:
                    continue

                duration = float(duration_match.group(1))

                # Extract query (normalize parameters)
                query_match = re.search(r'statement: (.+)', row['message'])
                if not query_match:
                    continue
                query = query_match.group(1)
                # Normalize: replace numbers/strings with placeholders
                normalized = re.sub(r"'[^']*'", "'?'", query)
                normalized = re.sub(r'd+', '?', normalized)

                # Update statistics
                stats = query_stats[normalized]
                stats['count'] += 1
                stats['total_time'] += duration
                stats['max_time'] = max(stats['max_time'], duration)

    # Sort by total time
    sorted_queries = sorted(
        query_stats.items(),
        key=lambda x: x[1]['total_time'],
        reverse=True
    )

    # Print report
    print("Top 10 Slow Query Patterns:")
    for query, stats in sorted_queries[:10]:
        avg_time = stats['total_time'] / stats['count']
        print(f"
Query: {query[:100]}...")
        print(f"  Count: {stats['count']}")
        print(f"  Total: {stats['total_time']:.1f}ms")
        print(f"  Avg: {avg_time:.1f}ms")
        print(f"  Max: {stats['max_time']:.1f}ms")
Result: Aggregates slow queries by pattern (ignoring parameter values). Identifies which query types consume the most database time. Focus optimization efforts here.

Database Monitoring Tools

Specialized tools provide dashboards, alerting, and historical analysis beyond what raw SQL queries offer.

pgAdmin

Web-based GUI for PostgreSQL administration and monitoring.

Features:
  • Dashboard with server metrics
  • Active query monitoring
  • Lock viewer (see blocking queries)
  • Visual EXPLAIN plans
  • Database object statistics
Best for: Development, ad-hoc investigation, single server
DataGrip

JetBrains IDE for database development with built-in monitoring.

Features:
  • Query console with execution plans
  • Live table data viewer
  • Schema comparison
  • SQL autocomplete and refactoring
  • Local query history
Best for: Development, query optimization, schema design
Prometheus + Grafana

Time-series metrics collection and visualization for production monitoring.

Features:
  • Time-series metrics storage
  • Beautiful dashboards (Grafana)
  • Alerting rules and notifications
  • Historical trend analysis
  • Multi-database fleet monitoring
Best for: Production, fleet management, alerting (recommended!)
Managed Service Tools

Cloud providers offer built-in monitoring for managed databases.

Examples:
  • AWS RDS Performance Insights
  • Google Cloud SQL Monitoring
  • Azure Database Insights
  • Heroku Postgres Metrics
Best for: Managed databases, easy setup, integrated alerts
Setting Up Prometheus + Grafana
# 1. Install postgres_exporter (exposes PostgreSQL metrics to Prometheus)
docker run -d \
  --name postgres-exporter \
  -p 9187:9187 \
  -e DATA_SOURCE_NAME="postgresql://postgres:password@localhost:5432/postgres?sslmode=disable" \
  prometheuscommunity/postgres-exporter

# 2. Configure Prometheus to scrape metrics (prometheus.yml)
scrape_configs:
  - job_name: 'postgresql'
    static_configs:
      - targets: ['localhost:9187']

# 3. Start Prometheus
docker run -d \
  --name prometheus \
  -p 9090:9090 \
  -v /path/to/prometheus.yml:/etc/prometheus/prometheus.yml \
  prom/prometheus
# 4. Start Grafana
docker run -d \
  --name grafana \
  -p 3000:3000 \
  grafana/grafana

# 5. Open Grafana: http://localhost:3000 (admin/admin)
# 6. Add Prometheus data source: http://prometheus:9090
# 7. Import PostgreSQL dashboard (ID: 9628)

# Result: Full-featured monitoring dashboard with:
# - QPS, connection count, cache hit rate
# - Query duration percentiles (P50, P95, P99)
# - Replication lag, deadlocks, locks
# - CPU, memory, disk I/O
# - 30-day historical data retention
Result: Professional production monitoring in 10 minutes. Grafana dashboards show real-time metrics with drill-downs. Prometheus stores historical data for trend analysis.

Alerting Strategies

Alerts wake engineers when thresholds are breached. Good alerts detect real problems; bad alerts cause alert fatigue and ignored warnings.

Alert Severity Levels
Info

FYI notifications, no immediate action needed.

Examples:
- Cache hit rate 95-97%
- Replication lag 5-10s
- Disk space 20-30% free

Action: Monitor, schedule
        optimization

Notification: Email, Slack
Warning

Degraded performance, investigate within hours.

Examples:
- Cache hit rate <95%
- Replication lag >30s
- Connections >70%
- Disk space <20%

Action: Investigate today,
        plan fix

Notification: Slack, PagerDuty
Critical

Service impacting, wake someone immediately.

Examples:
- Database unreachable
- Connections >95% (maxed)
- Disk space <10%
- Replication stopped

Action: Fix NOW (wake on-call)

Notification: PagerDuty, phone
Prometheus Alert Rules
# alert_rules.yml - Define alerting thresholds

groups:
  - name: postgresql_alerts
    interval: 30s
    rules:
      # High connection usage
      - alert: PostgreSQLConnectionsHigh
        expr: pg_stat_database_numbackends / pg_settings_max_connections > 0.8
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "PostgreSQL connections high ({{ $value | humanizePercentage }})"
          description: "Database {{ $labels.datname }} using >80% of max connections"

      # Low cache hit ratio
      - alert: PostgreSQLCacheHitRateLow
        expr: rate(pg_stat_database_blks_hit[5m]) /
              (rate(pg_stat_database_blks_hit[5m]) + rate(pg_stat_database_blks_read[5m])) < 0.95
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Cache hit ratio low ({{ $value | humanizePercentage }})"
          description: "Database {{ $labels.datname }} cache hit rate <95%"
      # Replication lag
      - alert: PostgreSQLReplicationLag
        expr: pg_replication_lag > 30
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Replication lag high ({{ $value }}s)"
          description: "Replica {{ $labels.instance }} is {{ $value }}s behind master"

      # Deadlocks detected
      - alert: PostgreSQLDeadlocks
        expr: rate(pg_stat_database_deadlocks[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Deadlocks detected ({{ $value }}/sec)"
          description: "Database {{ $labels.datname }} experiencing deadlocks"

# Load rules in Prometheus
prometheus --config.file=prometheus.yml --rules.file=alert_rules.yml
Result: Prometheus evaluates rules every 30 seconds. When conditions persist for specified duration (for clause), alert fires. Sent to Alertmanager for routing to Slack, PagerDuty, email, etc.
Python Script: Custom Alerting
import psycopg2
import requests
import time

SLACK_WEBHOOK = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

def check_database_health():
    """Monitor database and send alerts."""
    conn = psycopg2.connect("dbname=myapp user=postgres")
    cur = conn.cursor()

    # Check 1: Connection usage
    cur.execute("""
        SELECT count(*) AS active,
               current_setting('max_connections')::int AS max_conn
        FROM pg_stat_activity
        WHERE state != 'idle'
    """)
    active, max_conn = cur.fetchone()
    usage_pct = (active / max_conn) * 100

    if usage_pct > 80:
        send_alert(
            f"⚠️  High connection usage: {active}/{max_conn} ({usage_pct:.1f}%)",
            severity="warning"
        )
    # Check 2: Cache hit ratio
    cur.execute("""
        SELECT
            sum(blks_hit) / (sum(blks_hit) + sum(blks_read)) AS cache_hit_ratio
        FROM pg_stat_database
        WHERE datname = current_database()
    """)
    cache_hit_ratio = cur.fetchone()[0]

    if cache_hit_ratio < 0.95:
        send_alert(
            f"⚠️  Low cache hit ratio: {cache_hit_ratio:.2%}",
            severity="warning"
        )

    # Check 3: Long-running queries
    cur.execute("""
        SELECT count(*)
        FROM pg_stat_activity
        WHERE state = 'active'
          AND (now() - query_start) > interval '60 seconds'
    """)
    long_queries = cur.fetchone()[0]

    if long_queries > 5:
        send_alert(
            f"🚨 {long_queries} queries running >60 seconds",
            severity="critical"
        )

def send_alert(message, severity="warning"):
    """Send alert to Slack."""
    color = {"info": "#36a64f", "warning": "#ff9900", "critical": "#ff0000"}
    requests.post(SLACK_WEBHOOK, json={
        "attachments": [{
            "color": color[severity],
            "text": message
        }]
    })

# Run every 60 seconds
while True:
    check_database_health()
    time.sleep(60)
Result: Custom monitoring script for organizations without Prometheus. Checks critical metrics every minute, sends Slack alerts on threshold breaches. Can be extended with email, PagerDuty, or custom webhooks.

Monitoring Best Practices

Do This
  • Monitor the four golden signals: latency, traffic, errors, saturation
  • Set up alerts before production: don't wait for the first outage
  • Use percentiles (P95, P99) not averages for latency
  • Enable pg_stat_statements in production (minimal overhead)
  • Log slow queries (>100ms) with execution plans
  • Monitor replication lag continuously (alert at >5s)
  • Set up dashboards for on-call engineers (Grafana)
  • Review slow query logs weekly to catch regressions
Avoid This
  • Don't alert on everything (alert fatigue causes ignored warnings)
  • Don't use average latency (hides outliers, use P95/P99)
  • Don't ignore disk space (full disk causes database crash)
  • Don't set thresholds too tight (alert at 80%+ connections; alerting at 50% causes unnecessary noise)
  • Don't monitor only production (staging needs monitoring too)
  • Don't forget to test alerts (simulate failures regularly)
  • Don't rely on database-only metrics (monitor application latency too)
  • Don't skip historical data (retention <7 days insufficient for analysis)

Production Database Monitoring Checklist

Before Going to Production
✓ Query Performance
  • Enable pg_stat_statements extension
  • Configure slow query logging (threshold: 100ms)
  • Set up dashboard showing P50/P95/P99 query latency
  • Alert on P99 latency >500ms
✓ Connections
  • Set max_connections appropriately (start with 100)
  • Configure connection pooling (PgBouncer)
  • Monitor active vs idle connections
  • Alert on >80% connection usage
✓ Caching
  • Monitor cache hit ratio (target >99%)
  • Alert if cache hit ratio <95%
  • Tune shared_buffers (25% of RAM)
  • Monitor buffer pool evictions
✓ Replication
  • Monitor replication lag continuously
  • Alert on lag >5 seconds
  • Critical alert on replication stopped
  • Test failover procedure
✓ Resources
  • Monitor CPU, memory, disk I/O
  • Alert on disk space <20% free
  • Track disk I/O wait times
  • Set up log rotation (prevent disk fill)
✓ Locks & Deadlocks
  • Monitor deadlock count (target: 0)
  • Track lock wait times
  • Alert on long-running queries (>60s)
  • Dashboard showing blocking queries
Recommended Stack: Prometheus + Grafana + postgres_exporter + PagerDuty. This covers 95% of production monitoring needs with proven open-source tools.