--- /dev/null
+package torrent
+
+type ClientStats struct {
+       ConnStats
+
+       // Ongoing outgoing dial attempts. There may be more than one dial going on per peer address due
+       // to hole-punch connect requests. The total may not match the sum of attempts for all Torrents
+       // if a Torrent is dropped while there are outstanding dials.
+       ActiveHalfOpenAttempts int
+}
 
 type Client struct {
        // An aggregate of stats over all connections. First in struct to ensure 64-bit alignment of
        // fields. See #262.
-       stats ConnStats
+       connStats ConnStats
 
        _mu    lockWithDeferreds
        event  sync.Cond
                fmt.Fprintf(w, "%s DHT server at %s:\n", s.Addr().Network(), s.Addr().String())
                writeDhtServerStatus(w, s)
        })
-       spew.Fdump(w, &cl.stats)
+       dumpStats(w, cl.statsLocked())
        torrentsSlice := cl.torrentsAsSlice()
        fmt.Fprintf(w, "# Torrents: %d\n", len(torrentsSlice))
        fmt.Fprintln(w)
 
 func (cl *Client) countHalfOpenFromTorrents() (count int) {
        for _, t := range cl.torrents {
-               for _, attempts := range t.halfOpen {
-                       count += len(attempts)
-               }
+               count += t.numHalfOpenAttempts()
        }
        return
 }
        return fmt.Sprintf("<%[1]T %[1]p>", cl)
 }
 
-// Returns connection-level aggregate stats at the Client level. See the comment on
+// Returns connection-level aggregate connStats at the Client level. See the comment on
 // TorrentStats.ConnStats.
 func (cl *Client) ConnStats() ConnStats {
-       return cl.stats.Copy()
+       return cl.connStats.Copy()
+}
+
+func (cl *Client) Stats() ClientStats {
+       cl.rLock()
+       defer cl.rUnlock()
+       return cl.statsLocked()
+}
+
+func (cl *Client) statsLocked() (stats ClientStats) {
+       stats.ConnStats = cl.connStats.Copy()
+       stats.ActiveHalfOpenAttempts = cl.numHalfOpen
+       return
 }
 
 func (cn *Peer) postHandshakeStats(f func(*ConnStats)) {
        t := cn.t
        f(&t.stats)
-       f(&t.cl.stats)
+       f(&t.cl.connStats)
 }
 
 // All ConnStats that include this connection. Some objects are not known
 
--- /dev/null
+package torrent
+
+import (
+       "io"
+
+       "github.com/davecgh/go-spew/spew"
+)
+
+func dumpStats[T any](w io.Writer, stats T) {
+       spew.NewDefaultConfig()
+       spew.Fdump(w, stats)
+}
 
        "github.com/anacrolix/missinggo/v2/pubsub"
        "github.com/anacrolix/multiless"
        "github.com/anacrolix/sync"
-       "github.com/davecgh/go-spew/spew"
        "github.com/pion/datachannel"
        "golang.org/x/exp/maps"
 
 
        fmt.Fprintf(w, "DHT Announces: %d\n", t.numDHTAnnounces)
 
-       spew.NewDefaultConfig()
-       spew.Fdump(w, t.statsLocked())
+       dumpStats(w, t.statsLocked())
 
        fmt.Fprintf(w, "webseeds:\n")
        t.writePeerStatuses(w, maps.Values(t.webSeeds))
 // connection.
 func (t *Torrent) allStats(f func(*ConnStats)) {
        f(&t.stats)
-       f(&t.cl.stats)
+       f(&t.cl.connStats)
 }
 
 func (t *Torrent) hashingPiece(i pieceIndex) bool {
        }
        return
 }
+
+func (t *Torrent) numHalfOpenAttempts() (num int) {
+       for _, attempts := range t.halfOpen {
+               num += len(attempts)
+       }
+       return
+}