Skip to content

Commit 3f723c3

Browse files
committed
feat(p2p): expose peer connection metadata
1 parent 08c930f commit 3f723c3

8 files changed

Lines changed: 123 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to StreamHive are documented here. This project follows [Sem
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **`p2p`**: `PeerSnapshots` exposes connected peer metadata for operational tooling.
10+
11+
### Changed
12+
13+
- **Ops**: `/peers` now includes local address, connection timestamp, and connection age.
14+
715
## [0.6.0] — 2026-07-02
816

917
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Inspect connected peers:
5050
curl -s http://127.0.0.1:8080/peers
5151
```
5252

53-
Look for `replication_blobs_stored`, `replication_bytes_stored`, duplicate counters, and transport frame counters. The sender derives the blob key from `SHA-256(put-data)` when `-put-content-key` is set; receivers verify SHA-256-shaped keys before storing. Use `/metrics` for JSON counters, `/metrics/prometheus` for Prometheus text format, and `/peers` for a sorted peer snapshot.
53+
Look for `replication_blobs_stored`, `replication_bytes_stored`, duplicate counters, and transport frame counters. The sender derives the blob key from `SHA-256(put-data)` when `-put-content-key` is set; receivers verify SHA-256-shaped keys before storing. Use `/metrics` for JSON counters, `/metrics/prometheus` for Prometheus text format, and `/peers` for sorted peer metadata including remote address, local address, direction, connection timestamp, and connection age.
5454

5555
Or run the whole flow:
5656

@@ -108,7 +108,7 @@ go run . -listen 127.0.0.1:7070 -replicate -store-dir ./streamhive-data
108108

109109
| Import | Purpose |
110110
|--------|---------|
111-
| `github.com/AliSinaDevelo/StreamHive/p2p` | `TCPTransport`, framing (`ReadFrame` / `WriteFrame`), metrics |
111+
| `github.com/AliSinaDevelo/StreamHive/p2p` | `TCPTransport`, framing (`ReadFrame` / `WriteFrame`), peer snapshots, metrics |
112112
| `github.com/AliSinaDevelo/StreamHive/replication` | Blob replication messages (`blob.put`, `blob.has`, `blob.get`, `blob.missing`) and store apply helper |
113113
| `github.com/AliSinaDevelo/StreamHive/storage` | `BlobStore`, `BlobKeyLister`, `MemoryStore`, `FileStore`, SHA-256 content key helpers |
114114

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ flowchart TD
1717

1818
## Layers
1919

20-
1. **Transport (`p2p`)**`TCPTransport` with `context.Context` on `ListenAndAccept` / `Dial`, accept-loop shutdown coordinated with `Close`, optional TLS, optional framed reads via `FrameHandler`, metrics, and peer disconnect hooks.
20+
1. **Transport (`p2p`)**`TCPTransport` with `context.Context` on `ListenAndAccept` / `Dial`, accept-loop shutdown coordinated with `Close`, optional TLS, optional framed reads via `FrameHandler`, metrics, peer snapshots, and peer disconnect hooks.
2121
2. **Framing (`p2p`)**`SHV1` length-prefixed payloads (`ReadFrame` / `WriteFrame`) with a configurable maximum size (DoS bound). Application-level handshake string: `HandshakeVersionV1`.
2222
3. **Replication (`replication`)** — typed JSON messages carried inside frames. `blob.put` writes one key/value blob to a receiving `BlobStore`; `blob.has`, `blob.get`, and `blob.missing` provide the inventory/request vocabulary for anti-entropy sync.
2323
4. **Storage (`storage`)**`BlobStore` interface with `BlobKeyLister` inventory support, `MemoryStore` for tests/demos, and `FileStore` for durable local blobs; SHA-256 helpers provide stable content-addressed keys.
@@ -137,7 +137,7 @@ Implemented:
137137
- Message types: `blob.put`, `blob.has`, `blob.get`, and `blob.missing`.
138138
- Startup anti-entropy for connected `-replicate` peers.
139139
- Receiver-side storage via `storage.MemoryStore` or durable `storage.FileStore` with `-store-dir`.
140-
- JSON `/peers` snapshots for connected peer addresses/direction.
140+
- JSON `/peers` snapshots for connected peer addresses, direction, connection timestamp, and connection age.
141141
- JSON `/metrics` counters for stored/sent blobs, bytes, duplicates, and replication errors.
142142

143143
Not implemented yet:

docs/DEPLOYMENT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ docker run --rm -p 7070:7070 -p 8080:8080 streamhive:local \
1212
```
1313

1414
- **7070** — P2P TCP listener (example).
15-
- **8080** — HTTP `/livez`, `/readyz`, `/peers` (JSON peer snapshot), `/metrics` (JSON counters), `/metrics/prometheus` (Prometheus text).
15+
- **8080** — HTTP `/livez`, `/readyz`, `/peers` (JSON peer metadata), `/metrics` (JSON counters), `/metrics/prometheus` (Prometheus text).
1616

1717
Use TLS flags (`-tls-cert`, `-tls-key`, `-tls-ca`, …) when exposing services beyond a lab network.
1818

@@ -104,5 +104,5 @@ Define error budgets once you expose a workload to users. Baseline probes:
104104

105105
- **Availability**: `/livez` success rate.
106106
- **Readiness**: `/readyz` reflects listener bound (`TCPTransport.Ready`).
107-
- **Peer visibility**: `/peers` returns active connected peers and whether each connection is outbound.
107+
- **Peer visibility**: `/peers` returns active connected peers with remote address, local address, direction, connection timestamp, and connection age.
108108
- **Saturation**: JSON `/metrics` fields `active_peers` and `peers_rejected`, or Prometheus samples from `/metrics/prometheus`.

main.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -696,21 +696,35 @@ func (m *replicationMetrics) Snapshot() map[string]int64 {
696696
}
697697

698698
type peerStatus struct {
699-
RemoteAddr string `json:"remote_addr"`
700-
Outbound bool `json:"outbound"`
699+
RemoteAddr string `json:"remote_addr"`
700+
LocalAddr string `json:"local_addr,omitempty"`
701+
Outbound bool `json:"outbound"`
702+
ConnectedAt string `json:"connected_at,omitempty"`
703+
ConnectedForMS int64 `json:"connected_for_ms"`
701704
}
702705

703706
type peersResponse struct {
704707
ActivePeers int `json:"active_peers"`
705708
Peers []peerStatus `json:"peers"`
706709
}
707710

708-
func snapshotPeers(peers []p2p.Peer) peersResponse {
711+
func snapshotPeers(peers []p2p.PeerSnapshot, now time.Time) peersResponse {
709712
statuses := make([]peerStatus, 0, len(peers))
710713
for _, peer := range peers {
714+
connectedAt := ""
715+
var connectedForMS int64
716+
if !peer.ConnectedAt.IsZero() {
717+
connectedAt = peer.ConnectedAt.UTC().Format(time.RFC3339Nano)
718+
if now.After(peer.ConnectedAt) {
719+
connectedForMS = now.Sub(peer.ConnectedAt).Milliseconds()
720+
}
721+
}
711722
statuses = append(statuses, peerStatus{
712-
RemoteAddr: peer.RemoteAddr().String(),
713-
Outbound: peer.IsOutbound(),
723+
RemoteAddr: peer.RemoteAddr,
724+
LocalAddr: peer.LocalAddr,
725+
Outbound: peer.Outbound,
726+
ConnectedAt: connectedAt,
727+
ConnectedForMS: connectedForMS,
714728
})
715729
}
716730
sort.Slice(statuses, func(i, j int) bool {
@@ -753,7 +767,7 @@ func startHealth(addr string, tr *p2p.TCPTransport, replMetrics *replicationMetr
753767
w.Header().Set("Content-Type", "application/json")
754768
enc := json.NewEncoder(w)
755769
enc.SetIndent("", " ")
756-
_ = enc.Encode(snapshotPeers(tr.Peers()))
770+
_ = enc.Encode(snapshotPeers(tr.PeerSnapshots(), time.Now()))
757771
})
758772
mux.HandleFunc("/metrics/prometheus", func(w http.ResponseWriter, _ *http.Request) {
759773
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")

main_test.go

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,6 @@ func (testPeer) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0,
3434
func (testPeer) Close() error { return nil }
3535
func (testPeer) IsOutbound() bool { return false }
3636

37-
type staticPeer struct {
38-
addr string
39-
outbound bool
40-
}
41-
42-
func (p staticPeer) RemoteAddr() net.Addr { return staticAddr(p.addr) }
43-
func (p staticPeer) Close() error { return nil }
44-
func (p staticPeer) IsOutbound() bool { return p.outbound }
45-
46-
type staticAddr string
47-
48-
func (a staticAddr) Network() string { return "tcp" }
49-
func (a staticAddr) String() string { return string(a) }
50-
5137
func (s *safeBuffer) Write(p []byte) (int, error) {
5238
s.mu.Lock()
5339
defer s.mu.Unlock()
@@ -630,15 +616,29 @@ func TestWritePrometheusMetrics(t *testing.T) {
630616
}
631617

632618
func TestSnapshotPeersSortsByAddress(t *testing.T) {
633-
resp := snapshotPeers([]p2p.Peer{
634-
staticPeer{addr: "127.0.0.1:9002", outbound: true},
635-
staticPeer{addr: "127.0.0.1:9001", outbound: false},
636-
})
619+
connectedAt := time.Date(2026, 7, 2, 1, 2, 3, 4, time.UTC)
620+
now := connectedAt.Add(1500 * time.Millisecond)
621+
resp := snapshotPeers([]p2p.PeerSnapshot{
622+
{RemoteAddr: "127.0.0.1:9002", LocalAddr: "127.0.0.1:7002", Outbound: true, ConnectedAt: connectedAt},
623+
{RemoteAddr: "127.0.0.1:9001", LocalAddr: "127.0.0.1:7001", Outbound: false, ConnectedAt: connectedAt},
624+
}, now)
637625

638626
require.Equal(t, 2, resp.ActivePeers)
639627
require.Equal(t, []peerStatus{
640-
{RemoteAddr: "127.0.0.1:9001", Outbound: false},
641-
{RemoteAddr: "127.0.0.1:9002", Outbound: true},
628+
{
629+
RemoteAddr: "127.0.0.1:9001",
630+
LocalAddr: "127.0.0.1:7001",
631+
Outbound: false,
632+
ConnectedAt: "2026-07-02T01:02:03.000000004Z",
633+
ConnectedForMS: 1500,
634+
},
635+
{
636+
RemoteAddr: "127.0.0.1:9002",
637+
LocalAddr: "127.0.0.1:7002",
638+
Outbound: true,
639+
ConnectedAt: "2026-07-02T01:02:03.000000004Z",
640+
ConnectedForMS: 1500,
641+
},
642642
}, resp.Peers)
643643
}
644644

p2p/tcp_transport.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,31 @@ var ErrTransportClosed = errors.New("p2p: transport closed")
2424

2525
// TCPPeer is a TCP-backed Peer.
2626
type TCPPeer struct {
27-
conn net.Conn
28-
outbound bool
27+
conn net.Conn
28+
outbound bool
29+
connectedAt time.Time
2930
}
3031

3132
// NewTCPPeer wraps a connection as a Peer.
3233
func NewTCPPeer(conn net.Conn, outbound bool) *TCPPeer {
33-
return &TCPPeer{conn: conn, outbound: outbound}
34+
return &TCPPeer{conn: conn, outbound: outbound, connectedAt: time.Now().UTC()}
3435
}
3536

3637
// RemoteAddr returns the remote network address.
3738
func (p *TCPPeer) RemoteAddr() net.Addr { return p.conn.RemoteAddr() }
3839

40+
// LocalAddr returns the local network address.
41+
func (p *TCPPeer) LocalAddr() net.Addr { return p.conn.LocalAddr() }
42+
3943
// Close closes the connection.
4044
func (p *TCPPeer) Close() error { return p.conn.Close() }
4145

4246
// IsOutbound reports whether this peer was created from a dial (outbound).
4347
func (p *TCPPeer) IsOutbound() bool { return p.outbound }
4448

49+
// ConnectedAt reports when this peer was registered locally.
50+
func (p *TCPPeer) ConnectedAt() time.Time { return p.connectedAt }
51+
4552
// Conn returns the underlying connection for protocol codecs.
4653
func (p *TCPPeer) Conn() net.Conn { return p.conn }
4754

@@ -52,6 +59,14 @@ func (p *TCPPeer) WriteFrame(payload []byte, maxPayload int) error {
5259

5360
var _ Peer = (*TCPPeer)(nil)
5461

62+
// PeerSnapshot is a point-in-time description of a connected peer.
63+
type PeerSnapshot struct {
64+
RemoteAddr string
65+
LocalAddr string
66+
Outbound bool
67+
ConnectedAt time.Time
68+
}
69+
5570
// TCPTransport listens on TCP and tracks connected peers.
5671
type TCPTransport struct {
5772
ListenAddress string
@@ -115,6 +130,25 @@ func (t *TCPTransport) Peers() []Peer {
115130
return peers
116131
}
117132

133+
// PeerSnapshots returns stable metadata for currently connected peers.
134+
func (t *TCPTransport) PeerSnapshots() []PeerSnapshot {
135+
t.mu.RLock()
136+
defer t.mu.RUnlock()
137+
snapshots := make([]PeerSnapshot, 0, len(t.peers))
138+
for _, peer := range t.peers {
139+
snapshot := PeerSnapshot{
140+
RemoteAddr: peer.RemoteAddr().String(),
141+
Outbound: peer.IsOutbound(),
142+
}
143+
if tcpPeer, ok := peer.(*TCPPeer); ok {
144+
snapshot.LocalAddr = tcpPeer.LocalAddr().String()
145+
snapshot.ConnectedAt = tcpPeer.ConnectedAt()
146+
}
147+
snapshots = append(snapshots, snapshot)
148+
}
149+
return snapshots
150+
}
151+
118152
func (t *TCPTransport) logger() *slog.Logger {
119153
if t.Logger != nil {
120154
return t.Logger

p2p/tcp_transport_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,38 @@ func TestPeersReturnsSnapshot(t *testing.T) {
149149
assert.True(t, clientPeers[0].IsOutbound())
150150
}
151151

152+
func TestPeerSnapshotsIncludesConnectionMetadata(t *testing.T) {
153+
ctx := context.Background()
154+
before := time.Now().UTC()
155+
server := NewTCPTransport("127.0.0.1:0")
156+
server.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
157+
require.NoError(t, server.ListenAndAccept(ctx))
158+
defer func() { _ = server.Close() }()
159+
160+
client := NewTCPTransport("127.0.0.1:0")
161+
client.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
162+
require.NoError(t, client.ListenAndAccept(ctx))
163+
defer func() { _ = client.Close() }()
164+
165+
require.NoError(t, client.Dial(ctx, server.Addr().String()))
166+
waitFor(t, func() bool {
167+
return len(server.PeerSnapshots()) == 1 && len(client.PeerSnapshots()) == 1
168+
})
169+
170+
serverSnapshot := server.PeerSnapshots()[0]
171+
clientSnapshot := client.PeerSnapshots()[0]
172+
assert.False(t, serverSnapshot.Outbound)
173+
assert.True(t, clientSnapshot.Outbound)
174+
assert.NotEmpty(t, serverSnapshot.RemoteAddr)
175+
assert.NotEmpty(t, serverSnapshot.LocalAddr)
176+
assert.NotEmpty(t, clientSnapshot.RemoteAddr)
177+
assert.NotEmpty(t, clientSnapshot.LocalAddr)
178+
assert.False(t, serverSnapshot.ConnectedAt.Before(before))
179+
assert.False(t, clientSnapshot.ConnectedAt.Before(before))
180+
assert.False(t, serverSnapshot.ConnectedAt.After(time.Now().UTC()))
181+
assert.False(t, clientSnapshot.ConnectedAt.After(time.Now().UTC()))
182+
}
183+
152184
func TestDial_contextCancelled(t *testing.T) {
153185
ctx := context.Background()
154186
server := NewTCPTransport("127.0.0.1:0")

0 commit comments

Comments
 (0)