Skip to content

Collect from an SNMP-only device

  • “Our gNMI rollout hasn’t reached the 1U closet switches yet — but they still need CAM visibility.”
  • “This is a vendor-X switch that doesn’t ship gNMI at all. SNMPv2c is the only telemetry it supports.”
  • “We want a backstop in case gNMI streaming goes silent.”

SNMP is the universal backbone. Every managed switch built in the last two decades speaks it. l2trace’s SNMP collector polls the Q-BRIDGE-MIB (VLAN-aware FDB) and IF-MIB (port names) on a configurable cadence, joins the two tables, and emits the same MacLearned envelopes the gNMI collector emits.

The collector does three sequential GETBULK walks per poll cycle:

OIDPurpose
1.3.6.1.2.1.17.7.1.2.2.1.2 (dot1qTpFdbPort)VLAN-indexed MAC table
1.3.6.1.2.1.17.1.4.1.2 (dot1dBasePortIfIndex)Bridge port → ifIndex map
1.3.6.1.2.1.31.1.1.1.1 (ifName)ifIndex → port name (with ifDescr fallback for older devices)

dot1qTpFdbPort’s OID index is the load-bearing tricky bit: <vlan>.<mac-as-6-dotted-octets>. The varBind name 1.3.6.1.2.1.17.7.1.2.2.1.2.10.0.27.131.176.55.18 decodes to VLAN 10, MAC 00:1b:83:b0:37:12.

Sequential walks (not parallel) because most switches throttle parallel SNMP queries. Total walk volume on a 48-port access switch is ~5 KB per subtree; the round-trip cost is dominated by per-walk latency, not by data volume.

The collector takes a CollectorConfig with these fields:

from l2trace.collectors.base import CollectorConfig
from l2trace.collectors.snmp import SnmpCollector
from l2trace.events.schema import Source
cfg = CollectorConfig(
device_id=42,
hostname="sw-access-7",
mgmt_ip="10.0.0.7",
source=Source.SNMP,
auth={"community": "your-read-community"},
extras={"snmp_port": 161, "snmp_timeout_seconds": 5.0},
)
collector = SnmpCollector(cfg, emit=publish_to_nats, poll_interval_seconds=60.0)
await collector.run() # long-running loop

poll_interval_seconds defaults to 60s. Tune by device count + churn:

  • High-churn access tier (lots of laptop comings/goings): 30s
  • Stable data center fabric: 120s
  • Bandwidth-constrained WAN: 300s

The compactor’s SNMP_POLL_INTERVAL_SECONDS=120 default means rows stay “live” for at least 2× the poll interval, so a missed poll doesn’t immediately age a MAC out.

The test suite ships an in-process SNMPv2c mock agent (tests/fixtures/mock_snmp_agent.py) — a real pysnmp CommandResponder serving a static MIB tree. Useful for development without a real switch:

import asyncio
from tests.fixtures.mock_snmp_agent import MockSnmpAgent, build_canonical_walk
async def main():
walk = build_canonical_walk(
fdb=[(10, "00:1a:a1:11:22:33", 1, 3)], # (vlan, mac, port, status)
bridge_to_ifindex={1: 1001},
ifname={1001: "Gi0/1"},
)
async with MockSnmpAgent(oid_values=walk) as agent:
# The agent is listening on (agent.host, agent.port).
# Point the SnmpCollector at it.
...
asyncio.run(main())

The mock validates the full wire round trip — pysnmp GETBULK over UDP → varBind decode → parser → envelope — without needing a real device or external simulator.

CapabilitygNMISNMP
Streaming updates✅ subscribed paths push instantly❌ poll-based, latency = poll interval
Per-event device timestamp✅ nanosecond timestamp per notification❌ snapshot only — collector wall-clock
Forwarding-state diffs✅ explicit add/delete❌ snapshot diff; loses transients
Vendor support⚠️ Cisco IOS-XR, Arista, Juniper, Nokia✅ Universal

The reconciler’s source-priority order (gnmi > snmp > netconf > ssh) means SNMP observations are kept but get downgraded when gNMI is also running for the same MAC. The disagreement view surfaces cases where SNMP and gNMI disagree about a port — usually that means gNMI saw a move SNMP hasn’t polled yet.

  • The collector source: src/l2trace/collectors/snmp.py
  • The mock agent: tests/fixtures/mock_snmp_agent.py
  • Event envelope reference — what the four timestamps mean when SNMP can only provide collector wall-clock
  • Why bitemporal? — SNMP polls can arrive AFTER faster gNMI updates; the bitemporal model handles this natively as belief revision