Skip to content

Commit 06d9df3

Browse files
thomas-manginclaude
andcommitted
feat: Add received prefix counters to neighbor display (#978)
Track per-NLRI counters (received prefixes, withdrawn prefixes) in peer stats. Displayed in summary (#pfx_in column), extensive (Prefix Statistic section), and JSON API output. Counters reset on session down, matching Cisco/Juniper behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 86dcf31 commit 06d9df3

File tree

5 files changed

+48
-2
lines changed

5 files changed

+48
-2
lines changed

src/exabgp/bgp/neighbor/neighbor.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,12 @@ class NeighborTemplate:
432432
433433
Message Statistic Sent Received
434434
{messages}
435+
436+
Prefix Statistic
437+
{prefixes}
435438
""".replace('\t', ' ')
436439

437-
summary_header: ClassVar[str] = 'Peer AS up/down state | #sent #recvd'
440+
summary_header: ClassVar[str] = 'Peer AS up/down state | #sent #recvd #pfx_in'
438441
summary_template: ClassVar[str] = '%-15s %-7s %9s %-12s %10d %10d'
439442

440443
@classmethod
@@ -655,6 +658,9 @@ def as_dict(cls, answer: dict[str, Any]) -> dict[str, Any]:
655658
formated['peer']['id'] = answer['peer-id']
656659
formated['peer']['hold'] = answer['peer-hold']
657660

661+
if 'prefixes' in answer:
662+
formated['prefixes'] = answer['prefixes']
663+
658664
return formated
659665

660666
@classmethod
@@ -685,6 +691,9 @@ def formated_dict(cls, answer: dict[str, Any]) -> dict[str, Any]:
685691
'messages': '\n'.join(
686692
f' {f"{k}:":<20} {ms!s:>15} {mr!s:>15} {"":<15}' for k, (ms, mr) in answer['messages'].items()
687693
),
694+
'prefixes': '\n'.join(
695+
f' {f"{k}:":<20} {v!s:>15} {"":<15} {"":<15}' for k, v in answer.get('prefixes', {}).items()
696+
),
688697
}
689698

690699
return formated
@@ -709,7 +718,8 @@ def summary(cls, answer: dict[str, Any]) -> str:
709718
state_str = answer['state'].lower()
710719
update_in = answer['messages']['update'][0]
711720
update_out = answer['messages']['update'][1]
712-
return f'{peer_addr:<15} {peer_as_str:<7} {duration_str:>9} {state_str:<12} {update_in:>10} {update_out:>10}'
721+
prefixes_rcvd = answer.get('prefixes', {}).get('received', 0)
722+
return f'{peer_addr:<15} {peer_as_str:<7} {duration_str:>9} {state_str:<12} {update_in:>10} {update_out:>10} {prefixes_rcvd:>10}'
713723

714724

715725
# Initialize the empty neighbor singleton

src/exabgp/reactor/peer/context.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
if TYPE_CHECKING:
1313
from exabgp.bgp.neighbor import Neighbor
1414
from exabgp.bgp.message.open.capability import Negotiated
15+
from exabgp.reactor.peer.stats import Stats
1516
from exabgp.reactor.protocol import Protocol
1617

1718

@@ -29,3 +30,4 @@ class PeerContext:
2930
refresh_enhanced: bool
3031
routes_per_iteration: int
3132
peer_id: str
33+
stats: Stats

src/exabgp/reactor/peer/handlers/update.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def handle(self, ctx: PeerContext, message: Message) -> Generator[Message, None,
5151
nlri = routed.nlri
5252
route = Route(nlri, parsed.attributes, nexthop=routed.nexthop)
5353
ctx.neighbor.rib.incoming.update_cache(route)
54+
ctx.stats['receive-prefixes'] += 1
5455
log.debug(
5556
lazyformat('update.nlri number=%d nlri=' % self._number, nlri, str),
5657
ctx.peer_id,
@@ -59,6 +60,7 @@ def handle(self, ctx: PeerContext, message: Message) -> Generator[Message, None,
5960
# Process withdraws - use dedicated method
6061
for nlri in parsed.withdraws:
6162
ctx.neighbor.rib.incoming.update_cache_withdraw(nlri)
63+
ctx.stats['receive-withdraws'] += 1
6264
log.debug(
6365
lazyformat('update.nlri number=%d nlri=' % self._number, nlri, str),
6466
ctx.peer_id,
@@ -84,6 +86,7 @@ async def handle_async(self, ctx: PeerContext, message: Message) -> None:
8486
nlri = routed.nlri
8587
route = Route(nlri, parsed.attributes, nexthop=routed.nexthop)
8688
ctx.neighbor.rib.incoming.update_cache(route)
89+
ctx.stats['receive-prefixes'] += 1
8790
log.debug(
8891
lazyformat('update.nlri number=%d nlri=' % self._number, nlri, str),
8992
ctx.peer_id,
@@ -92,6 +95,7 @@ async def handle_async(self, ctx: PeerContext, message: Message) -> None:
9295
# Process withdraws - use dedicated method
9396
for nlri in parsed.withdraws:
9497
ctx.neighbor.rib.incoming.update_cache_withdraw(nlri)
98+
ctx.stats['receive-withdraws'] += 1
9599
log.debug(
96100
lazyformat('update.nlri number=%d nlri=' % self._number, nlri, str),
97101
ctx.peer_id,

src/exabgp/reactor/peer/peer.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ def __init__(self, neighbor: 'Neighbor', reactor: 'Reactor') -> None:
194194
'send-refresh': 0,
195195
'receive-keepalive': 0,
196196
'send-keepalive': 0,
197+
'receive-prefixes': 0,
198+
'receive-withdraws': 0,
197199
},
198200
)
199201

@@ -241,6 +243,8 @@ def _close(self, message: str = '', error: str | Exception = '') -> None:
241243
'send-refresh': 0,
242244
'receive-keepalive': 0,
243245
'send-keepalive': 0,
246+
'receive-prefixes': 0,
247+
'receive-withdraws': 0,
244248
},
245249
)
246250

@@ -665,6 +669,7 @@ async def _main(self) -> int:
665669
refresh_enhanced=refresh_enhanced,
666670
routes_per_iteration=routes_per_iteration,
667671
peer_id=self.id(),
672+
stats=self.stats,
668673
)
669674
update_handler = UpdateHandler()
670675
route_refresh_handler = RouteRefreshHandler(self.resend)
@@ -981,6 +986,11 @@ def cli_data(self) -> dict[str, Any]:
981986
messages[message] = (sent, rcvd)
982987
messages['total'] = (total_sent, total_rcvd)
983988

989+
prefixes = {
990+
'received': self.stats['receive-prefixes'],
991+
'withdrawn': self.stats['receive-withdraws'],
992+
}
993+
984994
return {
985995
'down': int(self.stats['reset'] - self.stats['creation']),
986996
'duration': (int(time.time() - self.stats['complete']) if self.stats['complete'] else 0),
@@ -996,4 +1006,5 @@ def cli_data(self) -> dict[str, Any]:
9961006
'capabilities': capabilities,
9971007
'families': families,
9981008
'messages': messages,
1009+
'prefixes': prefixes,
9991010
}

tests/unit/reactor/peer/handlers/test_update.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def mock_context(self) -> PeerContext:
2020
ctx.neighbor.rib = Mock()
2121
ctx.neighbor.rib.incoming = Mock()
2222
ctx.peer_id = 'test-peer'
23+
ctx.stats = {'receive-prefixes': 0, 'receive-withdraws': 0}
2324
return ctx
2425

2526
def test_can_handle_update(self, handler: UpdateHandler) -> None:
@@ -48,6 +49,8 @@ def test_handle_stores_nlris(self, handler: UpdateHandler, mock_context: PeerCon
4849
list(handler.handle(mock_context, update))
4950

5051
assert mock_context.neighbor.rib.incoming.update_cache.call_count == 2
52+
assert mock_context.stats['receive-prefixes'] == 2
53+
assert mock_context.stats['receive-withdraws'] == 0
5154

5255
def test_handle_empty_nlris(self, handler: UpdateHandler, mock_context: PeerContext) -> None:
5356
"""UpdateHandler handles updates with no NLRIs."""
@@ -84,6 +87,21 @@ def test_reset_clears_counter(self, handler: UpdateHandler) -> None:
8487
handler.reset()
8588
assert handler._number == 0
8689

90+
def test_handle_counts_withdraws(self, handler: UpdateHandler, mock_context: PeerContext) -> None:
91+
"""UpdateHandler increments withdraw counter per NLRI."""
92+
parsed = Mock()
93+
parsed.announces = []
94+
parsed.withdraws = [Mock(), Mock(), Mock()]
95+
parsed.attributes = Mock()
96+
update = Mock()
97+
update.TYPE = UpdateCollection.TYPE
98+
update.data = parsed
99+
100+
list(handler.handle(mock_context, update))
101+
102+
assert mock_context.stats['receive-prefixes'] == 0
103+
assert mock_context.stats['receive-withdraws'] == 3
104+
87105
def test_handle_is_generator(self, handler: UpdateHandler, mock_context: PeerContext) -> None:
88106
"""handle() returns a generator."""
89107
parsed = Mock()
@@ -112,6 +130,7 @@ def mock_context(self) -> PeerContext:
112130
ctx.neighbor.rib = Mock()
113131
ctx.neighbor.rib.incoming = Mock()
114132
ctx.peer_id = 'test-peer'
133+
ctx.stats = {'receive-prefixes': 0, 'receive-withdraws': 0}
115134
return ctx
116135

117136
@pytest.mark.asyncio

0 commit comments

Comments
 (0)