{
  "version": "1.1.0",
  "updated": "2026-10-31",
  "schema": "https://stitchdb.com/docs/ai/monitoring-checks",
  "about": "Detection checks and thresholds for StitchDB monitoring prompts. Each check names the read-only MCP tool that measures it, the metric, the threshold, the time window and what to compare against. A finding is reported as: measured value, threshold crossed, next step. Override thresholds with an OVERRIDES block in your prompt or with your own copy of this file.",
  "conventions": {
    "services_from_api_path": {
      "rest": "/rest/v1/",
      "auth": "/auth/v1/",
      "storage": "/storage/v1/",
      "functions": "/functions/v1/",
      "realtime": "/realtime/v1/"
    },
    "error": "an API request answered with HTTP status 500 or higher",
    "rate_pct": "errors / requests * 100 over the window",
    "compare": {
      "absolute": "the threshold alone decides",
      "previous_window": "also compare with the window just before (for example the previous hour); ratio is current / previous, with previous counted as at least 1",
      "baseline_7d": "compare with the daily average of the 7 days before",
      "plan_limit": "compare with the plan limit of the project (get_project shows usage and limits)",
      "state": "compare with the value saved in your alert state at the last run"
    },
    "severity": [
      "critical",
      "high",
      "medium",
      "low"
    ],
    "missing_tool": "If a tool named in source is not available on the connection, use source.fallback when there is one; otherwise mark the check skipped (once, in your state) and go on."
  },
  "checks": [
    {
      "id": "health.rest_errors",
      "area": "health",
      "title": "REST API error rate",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "filter": "path starts with /rest/v1/",
        "fallback": {
          "tool": "get_logs",
          "args": {
            "service": "api",
            "status": "5xx",
            "search": "/rest/v1/",
            "minutes": 60
          }
        }
      },
      "metric": "Share of /rest/v1 requests with status >= 500, and the number of such errors",
      "threshold": {
        "rate_pct": 5,
        "critical_pct": 20,
        "spike_ratio": 2,
        "min_requests": 20
      },
      "window": "60m",
      "compare": "previous_window",
      "rule": "rate_pct >= 5 with at least 20 requests, and either >= 2 x the previous hour or >= 20 (critical at >= 20)",
      "severity": "high",
      "next_step": "Group the 5xx requests by path and read the postgres logs of the same minutes (get_logs service=postgres level=error) to find the failing query or function."
    },
    {
      "id": "health.auth_errors",
      "area": "health",
      "title": "Auth error rate",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "filter": "path starts with /auth/v1/",
        "fallback": {
          "tool": "get_logs",
          "args": {
            "service": "auth",
            "level": "error",
            "minutes": 60
          }
        }
      },
      "metric": "Share of /auth/v1 requests with status >= 500, and the number of such errors",
      "threshold": {
        "rate_pct": 5,
        "critical_pct": 20,
        "spike_ratio": 2,
        "min_requests": 20
      },
      "window": "60m",
      "compare": "previous_window",
      "rule": "rate_pct >= 5 with at least 20 requests, and either >= 2 x the previous hour or >= 20 (critical at >= 20)",
      "severity": "high",
      "next_step": "Read the auth logs at error level for the same minutes (get_logs service=auth level=error); check SMTP, OAuth provider and Auth hook errors first."
    },
    {
      "id": "health.auth_failed_sign_ins",
      "area": "health",
      "title": "Failed sign-ins above normal",
      "source": {
        "tool": "query_logs",
        "args": {
          "query": "SELECT count(*) AS n FROM api WHERE method = 'POST' AND path LIKE '/auth/v1/token%' AND status >= 400 AND status < 500 AND status != 429",
          "since": "1h"
        },
        "fallback": {
          "tool": "get_logs",
          "args": {
            "service": "api",
            "search": "/auth/v1/token",
            "status": "4xx"
          }
        }
      },
      "metric": "Share of POST /auth/v1/token requests answered 4xx (not 429)",
      "threshold": {
        "rate_pct": 30,
        "critical_pct": 60,
        "spike_ratio": 2,
        "min_requests": 20
      },
      "window": "60m",
      "compare": "previous_window",
      "rule": "rate_pct >= 30 with at least 20 requests, and either >= 2 x the previous hour or >= 60",
      "severity": "medium",
      "next_step": "Look at the failing e-mails/IPs with query_logs (GROUP BY ip): many different accounts from few IPs is credential stuffing \u2014 turn on CAPTCHA and lower the sign-in rate limit; one app version failing points to a client bug."
    },
    {
      "id": "health.storage_errors",
      "area": "health",
      "title": "Storage error rate",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "filter": "path starts with /storage/v1/",
        "fallback": {
          "tool": "get_logs",
          "args": {
            "service": "api",
            "status": "5xx",
            "search": "/storage/v1/",
            "minutes": 60
          }
        }
      },
      "metric": "Share of /storage/v1 requests with status >= 500, and the number of such errors",
      "threshold": {
        "rate_pct": 5,
        "critical_pct": 20,
        "spike_ratio": 2,
        "min_requests": 20
      },
      "window": "60m",
      "compare": "previous_window",
      "rule": "rate_pct >= 5 with at least 20 requests, and either >= 2 x the previous hour or >= 20 (critical at >= 20)",
      "severity": "medium",
      "next_step": "Group the failing storage requests by bucket (second path segment) and method; check file size limits and the bucket's settings with list_storage_buckets."
    },
    {
      "id": "health.functions_errors",
      "area": "health",
      "title": "Edge Functions error rate",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "filter": "path starts with /functions/v1/",
        "fallback": {
          "tool": "get_edge_function_logs",
          "args": {}
        }
      },
      "metric": "Share of /functions/v1 requests with status >= 500 per function (third path segment), and the number of such errors",
      "threshold": {
        "rate_pct": 5,
        "critical_pct": 20,
        "spike_ratio": 2,
        "min_requests": 20
      },
      "window": "60m",
      "compare": "previous_window",
      "rule": "per function: rate_pct >= 5 with at least 20 requests, and either >= 2 x the previous hour or >= 20 (critical at >= 20)",
      "severity": "medium",
      "next_step": "Read the function's recent output (get_edge_function_logs function_slug=<name>) for boot errors, thrown exceptions and limit stops; compare with its last deploy time (list_edge_functions)."
    },
    {
      "id": "health.api_latency",
      "area": "health",
      "title": "Slow API responses",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "field": "ms"
      },
      "metric": "95th percentile of response time (ms) per service over the window",
      "threshold": {
        "p95_ms": 1500,
        "min_requests": 50,
        "spike_ratio": 2
      },
      "window": "60m",
      "compare": "previous_window",
      "rule": "p95_ms >= 1500 with at least 50 requests, and p95 >= 2 x the previous hour",
      "severity": "medium",
      "next_step": "List the slowest paths of the hour and run the performance checks (long-running queries, lock waits) for the same time."
    },
    {
      "id": "health.postgres_errors",
      "area": "health",
      "title": "Database errors",
      "source": {
        "tool": "get_logs",
        "args": {
          "service": "postgres",
          "level": "error",
          "minutes": 60
        }
      },
      "metric": "Number of postgres log lines at error level or above in the window, grouped by message without literals",
      "threshold": {
        "min_errors": 20,
        "spike_ratio": 3
      },
      "window": "60m",
      "compare": "previous_window",
      "rule": "errors >= 20 and errors >= 3 x the previous hour",
      "severity": "medium",
      "next_step": "Take the most frequent error message, find its SQLSTATE in the StitchDB docs (search_docs) and the query or function that raises it."
    },
    {
      "id": "health.connections",
      "area": "health",
      "title": "Connection pressure",
      "source": {
        "tool": "list_connections",
        "use": "summary.own (connections of your database roles) against summary.own_limit; summary.idle_in_transaction",
        "fallback": {
          "tool": "execute_sql",
          "sql": "select count(*)::int as used, current_setting('max_connections')::int as max, round(100.0 * count(*) / current_setting('max_connections')::int, 1) as used_pct, count(*) filter (where state = 'idle in transaction')::int as idle_in_tx from pg_stat_activity where backend_type = 'client backend'",
          "note": "only on a connection that is not read-only (read-only SQL cannot read pg_stat_activity)"
        }
      },
      "metric": "Connections of your database roles in use as a share of their connection limit (used_pct = summary.own / summary.own_limit * 100)",
      "threshold": {
        "used_pct": 80,
        "critical_pct": 95
      },
      "window": "now",
      "compare": "plan_limit",
      "rule": "used_pct >= 80 (critical at >= 95)",
      "severity": "high",
      "next_step": "Open Studio > Database > Connections to see which application and role hold the connections; connect apps through the pooler (port 6543) instead of direct connections."
    },
    {
      "id": "health.pooler_waiting",
      "area": "health",
      "title": "Clients waiting for a pooled connection",
      "source": {
        "tool": "get_advisors",
        "args": {
          "type": "health"
        }
      },
      "metric": "Average number of clients waiting for a free pooled connection over 5 minutes",
      "threshold": {
        "clients": 1,
        "critical": 10
      },
      "window": "5m",
      "compare": "absolute",
      "rule": "clients >= 1 (critical at >= 10)",
      "severity": "medium",
      "next_step": "Find long transactions holding connections (Studio \u203a Database \u203a Connections, idle in transaction) and shorten them; if the load is real, raise the pool size or move to a larger plan."
    },
    {
      "id": "security.advisor_findings",
      "area": "security",
      "title": "Security advisor findings",
      "source": {
        "tool": "get_advisors",
        "args": {
          "type": "security"
        }
      },
      "metric": "Security findings by id and level (error, warn)",
      "threshold": {
        "levels": [
          "error",
          "warn"
        ]
      },
      "window": "now",
      "compare": "state",
      "rule": "any finding with level error or warn whose id is not in the alert state",
      "severity": "high",
      "next_step": "Review the finding's fix_sql, test it on a preview branch, then apply it as a migration (the agent itself must not apply it)."
    },
    {
      "id": "security.exposure",
      "area": "security",
      "title": "Data readable or writable by anyone",
      "source": {
        "tool": "check_exposure",
        "args": {}
      },
      "metric": "Critical exposure findings: tables an anonymous visitor or any signed-in user can read or change",
      "threshold": {
        "critical": 1
      },
      "window": "now",
      "compare": "state",
      "rule": "one or more critical findings that are not in the alert state",
      "severity": "critical",
      "next_step": "Enable Row Level Security on the table and add policies that match who should see each row; re-run check_exposure afterwards."
    },
    {
      "id": "security.auth_failures",
      "area": "security",
      "title": "Failed sign-ins",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "filter": "path starts with /auth/v1/token and status is 400, 401 or 429",
        "fallback": {
          "tool": "get_logs",
          "args": {
            "service": "auth",
            "search": "invalid",
            "minutes": 1440
          }
        }
      },
      "metric": "Failed sign-in attempts in the window, total and per client IP",
      "threshold": {
        "min_failures": 100,
        "spike_ratio": 3,
        "per_ip": 30
      },
      "window": "24h",
      "compare": "previous_window",
      "rule": "failures >= 100 and >= 3 x the previous 24 hours, or one IP with >= 30 failures",
      "severity": "high",
      "next_step": "Check whether the failures come from few IPs (credential stuffing) and turn on bot protection or a rate limit rule for the sign-in endpoint."
    },
    {
      "id": "security.authorization_failures",
      "area": "security",
      "title": "Requests refused (401/403)",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "filter": "status is 401 or 403",
        "fallback": {
          "tool": "get_logs",
          "args": {
            "service": "api",
            "status": "4xx",
            "minutes": 1440
          }
        }
      },
      "metric": "401 and 403 responses per service and path, as a share of all requests",
      "threshold": {
        "rate_pct": 5,
        "min_count": 50,
        "spike_ratio": 3
      },
      "window": "24h",
      "compare": "previous_window",
      "rule": "count >= 50 and (rate_pct >= 5 or count >= 3 x the previous 24 hours)",
      "severity": "medium",
      "next_step": "Look at the top paths: a sudden rise after a deploy usually means an expired or wrong API key in the app; scattered paths from one IP mean probing."
    },
    {
      "id": "security.permission_denied",
      "area": "security",
      "title": "Permission denied in the database",
      "source": {
        "tool": "get_logs",
        "args": {
          "service": "postgres",
          "search": "permission denied",
          "minutes": 1440
        }
      },
      "metric": "postgres errors with SQLSTATE 42501 (permission denied, row-level security violation) in the window, by table",
      "threshold": {
        "min_count": 20,
        "spike_ratio": 3
      },
      "window": "24h",
      "compare": "previous_window",
      "rule": "count >= 20 and >= 3 x the previous 24 hours",
      "severity": "medium",
      "next_step": "Compare the table's policies (list_policies) with what the app sends; a new feature writing to a table without an insert policy is the usual cause."
    },
    {
      "id": "security.storage_denials",
      "area": "security",
      "title": "Storage access refused",
      "source": {
        "tool": "query_logs",
        "logs": "api",
        "filter": "path starts with /storage/v1/ and status is 400, 403 or 404 on object paths"
      },
      "metric": "Refused storage requests per bucket",
      "threshold": {
        "min_count": 50,
        "spike_ratio": 3
      },
      "window": "24h",
      "compare": "previous_window",
      "rule": "count >= 50 and >= 3 x the previous 24 hours",
      "severity": "low",
      "next_step": "Check the storage.objects policies for the bucket (list_policies) and whether the app sends the user's token rather than only the anon key."
    },
    {
      "id": "performance.slow_queries",
      "area": "performance",
      "title": "Slow queries",
      "source": {
        "tool": "execute_sql",
        "requires_extension": "pg_stat_statements",
        "sql": "select queryid::text, calls, round(mean_exec_time::numeric, 1) as mean_ms, round(total_exec_time::numeric / 1000, 1) as total_s, left(query, 300) as query from pg_stat_statements where calls >= 20 order by mean_exec_time desc limit 10"
      },
      "metric": "Mean execution time per normalized query (pg_stat_statements; counters since the last reset)",
      "threshold": {
        "mean_ms": 500,
        "min_calls": 20
      },
      "window": "since_reset",
      "compare": "state",
      "rule": "mean_ms >= 500 with at least 20 calls, for a queryid not in state or with mean_ms >= 1.5 x the saved value",
      "severity": "medium",
      "next_step": "Run EXPLAIN (not EXPLAIN ANALYZE on writes) for the query and look for a sequential scan on a large table; add the missing index in a migration."
    },
    {
      "id": "performance.lock_waits",
      "area": "performance",
      "title": "Sessions waiting on locks",
      "source": {
        "tool": "list_connections",
        "use": "sessions with wait_event_type \"Lock\", blocked_by not empty and query_s >= 30; the blocking pids are in blocked_by (their sessions show blocking > 0)",
        "fallback": {
          "tool": "execute_sql",
          "sql": "select a.pid, a.usename, a.application_name, now() - a.query_start as waiting, pg_blocking_pids(a.pid) as blocked_by, left(a.query, 200) as query from pg_stat_activity a where a.wait_event_type = 'Lock' and now() - a.query_start > interval '30 seconds' order by waiting desc limit 20",
          "note": "only on a connection that is not read-only (read-only SQL cannot read pg_stat_activity)"
        }
      },
      "metric": "Sessions waiting on a lock for more than 30 seconds, with the blocking session",
      "threshold": {
        "waiting_s": 30,
        "min_sessions": 1
      },
      "window": "now",
      "compare": "absolute",
      "rule": "one or more sessions waiting >= 30 s (high when >= 5 sessions)",
      "severity": "medium",
      "next_step": "Look up the blocking pid in Studio > Database > Connections; if it is idle in transaction or a stuck migration, cancel or terminate it there."
    },
    {
      "id": "performance.long_running",
      "area": "performance",
      "title": "Long-running queries",
      "source": {
        "tool": "list_connections",
        "args": {
          "long_query_s": 300
        },
        "use": "sessions with long_running true (active for at least long_query_s seconds)",
        "fallback": {
          "tool": "execute_sql",
          "sql": "select pid, usename, application_name, state, now() - query_start as running, left(query, 200) as query from pg_stat_activity where backend_type = 'client backend' and state = 'active' and pid <> pg_backend_pid() and now() - query_start > interval '5 minutes' order by running desc limit 20",
          "note": "only on a connection that is not read-only (read-only SQL cannot read pg_stat_activity)"
        }
      },
      "metric": "Active queries running longer than 5 minutes",
      "threshold": {
        "running_min": 5
      },
      "window": "now",
      "compare": "absolute",
      "rule": "one or more active queries running >= 5 minutes",
      "severity": "medium",
      "next_step": "Check whether the query is an expected batch job; if not, cancel it in Studio > Database > Connections and add a statement_timeout for that role."
    },
    {
      "id": "performance.idle_in_transaction",
      "area": "performance",
      "title": "Idle in transaction",
      "source": {
        "tool": "list_connections",
        "args": {
          "idle_s": 300
        },
        "use": "sessions with idle_in_transaction true (idle inside a transaction for at least idle_s seconds)",
        "fallback": {
          "tool": "execute_sql",
          "sql": "select pid, usename, application_name, now() - state_change as idle_for, left(query, 200) as last_query from pg_stat_activity where state like 'idle in transaction%' and now() - state_change > interval '5 minutes' order by idle_for desc limit 20",
          "note": "only on a connection that is not read-only (read-only SQL cannot read pg_stat_activity)"
        }
      },
      "metric": "Sessions idle inside an open transaction for more than 5 minutes (they hold locks and connections)",
      "threshold": {
        "idle_min": 5
      },
      "window": "now",
      "compare": "absolute",
      "rule": "one or more sessions idle in transaction >= 5 minutes",
      "severity": "medium",
      "next_step": "Find the application that opened the transaction (application_name, usename) and make sure it commits or rolls back; set idle_in_transaction_session_timeout for that role."
    },
    {
      "id": "performance.advisor_findings",
      "area": "performance",
      "title": "Performance advisor findings",
      "source": {
        "tool": "get_advisors",
        "args": {
          "type": "performance"
        }
      },
      "metric": "Performance findings by id (missing primary keys, unindexed foreign keys, ...)",
      "threshold": {
        "levels": [
          "error",
          "warn"
        ]
      },
      "window": "now",
      "compare": "state",
      "rule": "any finding with level error or warn whose id is not in the alert state",
      "severity": "low",
      "next_step": "Apply the finding's fix_sql as a migration after checking it on a preview branch."
    },
    {
      "id": "performance.cache_hit",
      "area": "performance",
      "title": "Low cache hit rate",
      "source": {
        "tool": "execute_sql",
        "sql": "select round(100.0 * sum(blks_hit) / nullif(sum(blks_hit) + sum(blks_read), 0), 2) as hit_pct, sum(blks_hit + blks_read) as reads from pg_stat_database where datname = current_database()"
      },
      "metric": "Share of table and index reads answered from memory",
      "threshold": {
        "hit_pct_below": 95,
        "min_reads": 100000
      },
      "window": "since_reset",
      "compare": "absolute",
      "rule": "hit_pct < 95 with at least 100000 block reads",
      "severity": "low",
      "next_step": "Find the largest tables read by sequential scans (pg_stat_user_tables.seq_scan) and index them, or move to a larger compute size."
    },
    {
      "id": "resources.requests_growth",
      "area": "resources",
      "title": "API requests growth",
      "source": {
        "tool": "get_usage",
        "args": {}
      },
      "metric": "API requests of the last full day against the daily average of the 7 days before",
      "threshold": {
        "growth_ratio": 2,
        "min_requests": 10000
      },
      "window": "1d",
      "compare": "baseline_7d",
      "rule": "requests >= 10000 and >= 2 x the 7-day average",
      "severity": "low",
      "next_step": "Group yesterday's requests by path and client IP (query_logs) to tell real growth from a loop in a client or a scraper."
    },
    {
      "id": "resources.errors_growth",
      "area": "resources",
      "title": "Server errors growth",
      "source": {
        "tool": "get_usage",
        "args": {}
      },
      "metric": "5xx errors of the last full day against the daily average of the 7 days before",
      "threshold": {
        "growth_ratio": 2,
        "min_errors": 100
      },
      "window": "1d",
      "compare": "baseline_7d",
      "rule": "errors >= 100 and >= 2 x the 7-day average",
      "severity": "medium",
      "next_step": "Run the health prompt for the hour with the most errors to find the failing service."
    },
    {
      "id": "resources.database_size",
      "area": "resources",
      "title": "Database size",
      "source": {
        "tool": "get_project",
        "args": {},
        "also": "get_usage for the daily trend"
      },
      "metric": "Database size as a share of the plan's included size, and growth over the last 7 days",
      "threshold": {
        "used_pct": 80,
        "growth_pct_7d": 25
      },
      "window": "7d",
      "compare": "plan_limit",
      "rule": "used_pct >= 80, or size grew >= 25% in 7 days",
      "severity": "medium",
      "next_step": "List the largest tables (resources.table_growth) and check for logs or history tables without a retention job; raise the plan or project limit before it fills up."
    },
    {
      "id": "resources.storage_size",
      "area": "resources",
      "title": "File storage size",
      "source": {
        "tool": "get_project",
        "args": {},
        "also": "list_storage_buckets for size per bucket"
      },
      "metric": "File storage as a share of the plan's included storage",
      "threshold": {
        "used_pct": 80,
        "growth_pct_7d": 25
      },
      "window": "7d",
      "compare": "plan_limit",
      "rule": "used_pct >= 80, or storage grew >= 25% in 7 days",
      "severity": "low",
      "next_step": "Find the bucket that grew (list_storage_buckets) and add a file size limit or a clean-up job for old objects."
    },
    {
      "id": "resources.table_growth",
      "area": "resources",
      "title": "Fast-growing tables",
      "source": {
        "tool": "execute_sql",
        "sql": "select schemaname || '.' || relname as table_name, n_live_tup as rows, pg_total_relation_size(relid) as bytes, n_dead_tup as dead_rows from pg_stat_user_tables order by pg_total_relation_size(relid) desc limit 20"
      },
      "metric": "Size and rows of the 20 largest tables, compared with the values saved at the last run",
      "threshold": {
        "growth_pct": 50,
        "min_bytes": 104857600,
        "dead_pct": 30
      },
      "window": "1d",
      "compare": "state",
      "rule": "table >= 100 MB that grew >= 50% since the last run, or dead_rows >= 30% of rows",
      "severity": "low",
      "next_step": "Check whether the growth is expected (new feature, import); for log-like tables add partitioning or a retention job, for many dead rows check long transactions blocking vacuum."
    },
    {
      "id": "resources.connections_peak",
      "area": "resources",
      "title": "Connections close to the limit",
      "source": {
        "tool": "list_connections",
        "use": "summary.own against summary.own_limit (used_pct); save the value in state",
        "fallback": {
          "tool": "execute_sql",
          "sql": "select count(*)::int as used, current_setting('max_connections')::int as max from pg_stat_activity where backend_type = 'client backend'",
          "note": "only on a connection that is not read-only (read-only SQL cannot read pg_stat_activity)"
        }
      },
      "metric": "Client connections now against max_connections, and the highest value saved in state over the last 7 runs",
      "threshold": {
        "used_pct": 70
      },
      "window": "1d",
      "compare": "state",
      "rule": "used_pct >= 70 now or in any of the last 7 saved runs",
      "severity": "low",
      "next_step": "Check Reports > Database connections for the daily peak and move app traffic to the pooler (port 6543) before connections run out."
    }
  ]
}
