diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,22 @@
 
 ## [Unreleased]
 
+## [2.5.1] - 2026-11-11
+
+### Changed
+
+- Log levels say who has the problem. An error is now something wrong with
+  this node, and nothing a peer does is one:
+  - a peer that closes its connection is logged for debugging, and one
+    that sends something invalid as a warning; both were errors;
+  - headers that do not connect are logged once, as the warning that
+    disconnects the peer, and no longer as an error before it;
+  - connecting to a peer, its connection and its end are logged for
+    debugging, where they were information;
+  - the number of connected peers is logged for debugging at every change,
+    as a warning when the node loses its last peer, and as information
+    when it has peers again.
+
 ## [2.5.0] - 2026-11-10
 
 This release fixes the findings of a security review. Several fixes change
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -169,7 +169,7 @@
 
 ```yaml
 extra-deps:
-  - haskoin-node-2.5.0
+  - haskoin-node-2.5.1
   - haskoin-core-1.6.1
   - secp256k1-haskell-2.0.1
   - base16-2.0
@@ -198,7 +198,7 @@
    stack run
    ```
 
-   Within a few seconds it logs connections and starts printing header
+   Within a few seconds it has peers and starts printing header
    heights. A full header sync of Bitcoin usually takes minutes and on the
    order of a hundred megabytes of disk; it resumes where it stopped if you
    interrupt it.
@@ -494,6 +494,15 @@
 `withLogger level` logs to standard error, one line per message, such as
 `[Info#Chain] Best block header at height 870000`. Build a `Logger` with your
 own `push` function to log elsewhere.
+
+| Level | What it is for |
+| --- | --- |
+| Debug | Everything: each peer that connects or leaves, each message |
+| Info | Milestones: the best block, headers in sync, peers again after none |
+| Warn | Something unusual that the node dealt with: a misbehaving peer, no peers left |
+| Error | Something wrong with this node. Nothing that a peer does is an error |
+
+An alert on errors is then an alert about the node, not about the network.
 
 ## Configuration
 
diff --git a/haskoin-node.cabal b/haskoin-node.cabal
--- a/haskoin-node.cabal
+++ b/haskoin-node.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           haskoin-node
-version:        2.5.0
+version:        2.5.1
 synopsis:       P2P library for Bitcoin and Bitcoin Cash
 description:    Connect to Bitcoin (BTC) and Bitcoin Cash (BCH) peers, keep them healthy, and
                 sync block headers into a RocksDB database. Blocks and transactions can be
diff --git a/src/Haskoin/Node/Chain.hs b/src/Haskoin/Node/Chain.hs
--- a/src/Haskoin/Node/Chain.hs
+++ b/src/Haskoin/Node/Chain.hs
@@ -470,7 +470,8 @@
     chainEvent ch (ChainBestBlock after)
   case result of
     Left e -> do
-      logError ch.config.logger ("connectBlocks: " <> cs e)
+      -- the caller disconnects the peer and says why, with a warning
+      logDebug ch.config.logger ("connectBlocks: " <> cs e)
       return (Left e)
     Right [] -> return (Left "no headers")
     Right (lst : _) -> do
diff --git a/src/Haskoin/Node/Logger.hs b/src/Haskoin/Node/Logger.hs
--- a/src/Haskoin/Node/Logger.hs
+++ b/src/Haskoin/Node/Logger.hs
@@ -32,13 +32,17 @@
 
 -- | Severity of a log message, from least to most severe.
 data LogLevel
-  = -- | detailed tracing, useful when debugging
+  = -- | everything, for debugging: each peer that connects or leaves, each
+    -- message
     LevelDebug
-  | -- | normal operation, such as peers connecting and blocks arriving
+  | -- | what an operator wants in a quiet log: the best block, headers in
+    -- sync, peers again after having none
     LevelInfo
-  | -- | unexpected events that the node recovers from
+  | -- | something unusual that the node dealt with: a peer that sent
+    -- something invalid and was disconnected, no peers left
     LevelWarn
-  | -- | failures
+  | -- | something wrong with this node, that someone should look at.
+    -- Nothing that a peer does is an error.
     LevelError
   deriving (Eq, Ord, Show)
 
diff --git a/src/Haskoin/Node/Peer.hs b/src/Haskoin/Node/Peer.hs
--- a/src/Haskoin/Node/Peer.hs
+++ b/src/Haskoin/Node/Peer.hs
@@ -279,7 +279,7 @@
   yield m
   return True
 dispatchMessage PeerConfig {label, logger} KillPeer = do
-  liftIO $ logInfo logger (label <> " disconnecting")
+  liftIO $ logDebug logger (label <> " disconnecting")
   return False
 
 -- | Internal conduit to parse messages coming from peer. The header is checked
@@ -333,7 +333,13 @@
       _ -> return ()
     bad :: PeerException -> ConduitT i o IO a
     bad e = liftIO $ do
-      logError logger (label <> ": " <> cs (show e))
+      -- a peer that hangs up is the ordinary end of a connection; one that
+      -- sends what it should not is worth a look, and is not an error of
+      -- this node either
+      let level = case e of
+            ConnectionClosed -> logDebug
+            _ -> logWarn
+      level logger (label <> ": " <> cs (show e))
       throwIO e
     -- The reason is on the first line of the parser's error text. The lines
     -- after it are call stack noise.
diff --git a/src/Haskoin/Node/PeerMgr.hs b/src/Haskoin/Node/PeerMgr.hs
--- a/src/Haskoin/Node/PeerMgr.hs
+++ b/src/Haskoin/Node/PeerMgr.hs
@@ -171,9 +171,18 @@
     -- | addresses to connect to
     addresses :: !(TVar (AddressBook SockAddr)),
     -- | open connections, fastest peers first
-    peers :: !(TVar [OnlinePeer])
+    peers :: !(TVar [OnlinePeer]),
+    -- | how well connected the log last said the node was ('Connected')
+    told :: !(TVar Connected)
   }
 
+-- | How well connected the node is, as its log says at the level of
+-- information. Peers come and go all the time, and a line for each change
+-- buries the ones that matter: that the node lost its last peer, and that
+-- it has peers again.
+data Connected = NoPeers | SomePeers | AllPeers
+  deriving (Eq, Show)
+
 -- | Messages handled by the peer manager process.
 data PeerMgrMessage
   = -- | connect to a peer address
@@ -275,6 +284,7 @@
     best <- newTVarIO 0
     addresses <- newTVarIO emptyBook
     peers <- newTVarIO []
+    told <- newTVarIO NoPeers
     let mgr =
           PeerMgr
             { config = cfg,
@@ -282,7 +292,8 @@
               mailbox = inboxToMailbox inbox,
               best = best,
               addresses = addresses,
-              peers = peers
+              peers = peers,
+              told = told
             }
     withAsync (peerManager mgr inbox) $ \a ->
       withConnectLoop mgr (link a >> action mgr)
@@ -346,7 +357,7 @@
       ReplyVerAck -> MVerAck `sendMessage` p
       Announce -> do
         atomically $ modifyTVar' mgr.addresses (markConnected o.address)
-        logInfo mgr.config.logger ("Connected to peer " <> p.label)
+        logDebug mgr.config.logger ("Connected to peer " <> p.label)
         publish (PeerConnected p) mgr.config.pub
         logConnectedPeers mgr
       SendPing n -> MPing (Ping n) `sendMessage` p
@@ -405,7 +416,7 @@
   case gone of
     Nothing -> logError mgr.config.logger "Unknown peer process stopped"
     Just (o, outcome) -> do
-      logInfo mgr.config.logger $
+      logDebug mgr.config.logger $
         "Connection to peer "
           <> o.mailbox.label
           <> " ended: "
@@ -437,13 +448,24 @@
       fromException x
         <|> (fromException x >>= \(ExceptionInLinkedThread _ inner) -> fromException inner)
 
--- | Log how many peers are online out of the maximum.
+-- | Log how many peers are online out of the maximum: for debugging at
+-- every change, and at a level that an operator reads when the node loses
+-- its last peer or has one again ('Connected').
 logConnectedPeers :: PeerMgr -> IO ()
 logConnectedPeers mgr = do
   l <- length <$> getPeers mgr
-  logInfo
-    mgr.config.logger
-    ("Peers connected: " <> cs (show l) <> "/" <> cs (show mgr.config.maxPeers))
+  let now
+        | l == 0 = NoPeers
+        | l >= mgr.config.maxPeers = AllPeers
+        | otherwise = SomePeers
+      count = "Peers connected: " <> cs (show l) <> "/" <> cs (show mgr.config.maxPeers)
+  before <- atomically (swapTVar mgr.told now)
+  if
+    | now == before -> logDebug mgr.config.logger count
+    | now == NoPeers -> logWarn mgr.config.logger count
+    | before == NoPeers -> logInfo mgr.config.logger count
+    -- a node that rotates a peer is as well connected as it was
+    | otherwise -> logDebug mgr.config.logger count
 
 -- | Next address to connect to. The configured peers and, if discovery is on,
 -- the seeds are loaded when there is nothing to try and they were not loaded
@@ -499,7 +521,7 @@
     | source /= Just Configured && netGroup sa `elem` usedGroups ops ->
         giveBack "network group in use"
     | otherwise -> do
-        logInfo mgr.config.logger ("Connecting to peer " <> cs (show sa))
+        logDebug mgr.config.logger ("Connecting to peer " <> cs (show sa))
         nonce <- randomIO
         height <- readTVarIO mgr.best
         now <- getCurrentTime
