Kubernetes · #exporter · #meraki · #prometheus

Meraki Exporter: Scraping a slow, rate-limited API without getting throttled

The whole design turns on one decision: never call the Meraki API while Prometheus is watching. Background pollers refresh a cache on their own schedule; /metrics just replays it.

The problem: two clocks that don’t agree

Cisco Meraki runs cloud-managed networks — appliances, switches, access points, environmental sensors — and exposes their state through a Dashboard API. It’s a great source of truth for a monitoring stack. It’s also a difficult one to scrape.

The API is rate-limited to ten requests per second per organization, shared across every tool your org points at it. Individual calls are not fast, and several endpoints paginate across many pages, so a large org can take tens of seconds to fully enumerate.

Prometheus has the opposite temperament. It scrapes on a fixed interval and expects a prompt reply; a slow /metrics handler stalls the scrape, and a naïve exporter that fans out to the API on every scrape would burn through the rate limit, collide with other API consumers, and eventually eat an HTTP 429.

The architecture: poll in the background, serve from cache

So the exporter decouples the two clocks. Background pollers hit the Meraki API on their own unhurried schedule and store the resulting Prometheus metrics in memory. When Prometheus scrapes /metrics, the handler takes a read lock and replays the cache — no API call, no waiting.

data path — poll & cache
Meraki API slow · paginated 10 req/s cap fast poller · 120s devices · uplinks · wireless slow poller · 15m clients · licenses · alerts in-memory cache RWMutex Prometheus /metrics instant
API traffic flows left-to-right on the pollers’ schedule; scrapes read the cache on the right, decoupled from everything upstream.

The payoff is operational calm. Scrape latency is now independent of API latency, so you can scrape every 60s — or every 15s — without touching Meraki any more often. If the API has a bad minute, the cache serves the last good snapshot instead of failing the scrape. And because all API traffic funnels through one client, the exporter can hold the whole process to a strict request budget.

Poll groups: not everything changes at the same speed

Uplink status can flap in seconds; a license expiry date moves once a year. Polling both at the same cadence either wastes API budget on data that didn’t change or leaves fast-moving signals stale. So collectors are split into two groups, each with its own interval and its own metrics endpoint.

collector scheduling
fast group120s

The things you page on. Refreshed every --scrape.interval (minimum 30s).

devicesuplinkswireless
slow group15m

Slow-changing context. Refreshed every --scrape.slow-interval.

clientslicensesapiusagealerts

Group membership is just a config value (COLLECTORS_SLOW), so you can move a collector between tiers without a rebuild. Each group exposes /metrics/fast and /metrics/slow; the combined /metrics still serves both for backwards compatibility. Scrape either the combined endpoint or the fast/slow pair — never both.

The client: a well-behaved API citizen

Every request in the process funnels through one Client, and that’s where the good manners live. It self-throttles to about five requests a second — deliberately half of Meraki’s ceiling, leaving headroom for the humans and other tools sharing the org’s budget.

5 req/s
self-imposed ceiling — half the org budget
4 retries
backoff 1s → 2s → 4s → 8s on transient errors
429 aware
honors Retry-After to the second

The retry loop distinguishes what’s worth retrying from what isn’t. A 429 or a 5xx backs off and tries again; a 429 carrying a Retry-After header waits exactly that long. A 4xx that isn’t rate-limiting is a real error — bad key, wrong org — so it fails fast instead of hammering. Pagination follows the Link: rel=next header (accepting both the quoted and unquoted forms the API might send), and error messages strip the query string so an API key never lands in a log line.

// internal/meraki/client.go — the retry decision
switch {
case resp.StatusCode == http.StatusOK:
// read body, follow Link: rel=next for the next page
return body, next, -1, nil // -1 = do not retry
case resp.StatusCode == http.StatusTooManyRequests:
ra := parseRetryAfter(resp.Header.Get("Retry-After"), time.Second)
return nil, "", ra, fmt.Errorf("rate limited (429) on %s", redactURL(rawURL))
case resp.StatusCode >= http.StatusInternalServerError:
return nil, "", 0, fmt.Errorf("server error %d", resp.StatusCode) // 0 = default backoff
default:
// bad key, wrong org, unknown endpoint — a real error, fail fast
return nil, "", -1, fmt.Errorf("unexpected status %d", resp.StatusCode)
}

Least privilege. The exporter only ever issues GET requests, so it’s meant to run with a read-only Meraki admin key. The key is read from an environment variable rather than a flag, so it never shows up in ps output.

Modelling state: absence should never mask an outage

Here’s a subtle trap in status metrics. If you model device status as a single gauge and only emit the current state, a device going offline can look identical to a device that simply stopped reporting — the series you were alerting on just… disappears.

So status metrics are emitted as state sets: one series per known state, valued 1 for the current state and 0 for the rest. The full set is always present, so “offline” is an explicit 1, not a gap you have to notice.

meraki_device_status{ serial=”Q2XX…”, status=… }
status=“online”0
status=“alerting”0
status=“offline”1
status=“dormant”0
One device, four series, always all present. An alert on meraki_device_status{status="offline"} == 1 fires on a genuine outage and is never fooled by a series that quietly vanished.

The same shape covers uplink status, VPN peer reachability, switch-port state, and warm-spare HA role — where a spare appliance carrying an active uplink is exactly the “failover is happening right now” signal you want to catch. The exporter also publishes meta-metrics about itself — per-collector success and duration, and a last-poll timestamp per group — so you can alert on the exporter going stale, not just on the network.

What it collects: ten collectors, six on by default

Each collector maps a slice of the Meraki API to a set of meraki_* metrics. Six are on out of the box; four are opt-in, so you don’t spend API budget on hardware you don’t have.

CollectorWhat you getStatus
devicesInventory plus online / alerting / offline statusDefault
uplinksWAN uplink status, loss %, latency, warm-spare HA roleDefault
clientsClient counts, org-wide and per networkDefault
wirelessChannel utilization per AP and bandDefault
licensesLicense status and expiry timestampDefault
apiusageOrg-wide API response codes (all consumers)Default
vpnSite-to-site VPN peer reachabilityOpt-in
switchportsPort status, admin-enabled, PoE allocationOpt-in
sensorsTemperature, humidity, door, water, CO₂, TVOC, noise, batteryOpt-in
alertsOpen Assurance alerts by severity and categoryOpt-in

Run it: binary to dashboard in a few minutes

It ships as a single static binary with no runtime dependencies — grab a release, point it at your org, and wire Prometheus to scrape it.

1. Get the binary

Download a prebuilt release (linux/darwin, amd64/arm64) and verify the checksum — or build from source with Go 1.25+.

curl -LO https://github.com/machani/meraki-exporter/releases/latest/download/meraki-exporter-linux-amd64
chmod +x meraki-exporter-linux-amd64
# or build from source
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o meraki-exporter ./cmd/meraki-exporter

2. Configure with a read-only key

Every option is both a flag and an env var. Only the API key is required; the org is auto-discovered when the key sees exactly one.

# /etc/meraki-exporter.env
MERAKI_API_KEY=<read-only-key>
# MERAKI_ORG_ID=… # only needed if the key sees multiple orgs
LISTEN_ADDRESS=:9822
SCRAPE_INTERVAL=120s
SLOW_SCRAPE_INTERVAL=15m
COLLECTORS_ENABLED=devices,uplinks,clients,wireless,licenses,apiusage,vpn

3. Install as a systemd service

A unit file, env file, and a dedicated nologin user ship in deploy/. /healthz stays 503 until the first poll of each group finishes — a natural readiness probe.

sudo cp meraki-exporter /usr/local/bin/
sudo cp deploy/meraki-exporter.service /etc/systemd/system/
sudo systemctl enable --now meraki-exporter
curl -s localhost:9822/healthz # "ok" after the first poll
curl -s localhost:9822/metrics | grep meraki_

4. Scrape, alert, visualize

Point Prometheus at it — scraping faster than the poll interval just re-reads the cache, which is fine. Ready-made alerting rules and four importable Grafana dashboards (overview, device health, uplink/WAN, wireless) live in the repo.

# prometheus.yml
scrape_configs:
- job_name: meraki
scrape_interval: 60s
static_configs:
- targets: ['localhost:9822']

Budget check. At the default 120s interval, an org with 50 networks and fewer than 500 devices spends roughly 55 requests per cycle — about 0.5 req/s on average, comfortably inside Meraki’s 10 req/s ceiling. Larger org? Raise the intervals or drop the per-network clients collector.

Takeaways: what travels beyond Meraki

The specifics are Meraki’s, but the shape is reusable for any exporter wrapping a slow or metered upstream:

  • Decouple scrape cadence from fetch cadence. Cache in the exporter and serve scrapes from memory. Your monitoring system’s clock should never drive your upstream’s load.
  • Tier your polling. Fast-moving and slow-moving data don’t deserve the same interval or the same budget.
  • Be a good API citizen. Throttle below the ceiling, honor Retry-After, back off on transient errors, fail fast on real ones, and never log the key.
  • Model status so absence can’t hide. State sets make “offline” an explicit value, not a missing series.
  • Instrument the exporter itself, so a stuck poller is as visible as a downed device.

A single Go binary, four dashboards, and a set of alerting rules — pointed at a read-only key, it turns a rate-limited API into a calm Prometheus target.

View on GitHub → · Releases

Responses

No comments yet · be the first

Leave a Reply