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.
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.
The things you page on. Refreshed every --scrape.interval (minimum 30s).
Slow-changing context. Refreshed every --scrape.slow-interval.
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.
Retry-After to the secondThe 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 decisionswitch {case resp.StatusCode == http.StatusOK: // read body, follow Link: rel=next for the next page return body, next, -1, nil // -1 = do not retrycase 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 backoffdefault: // 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
GETrequests, 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 inpsoutput.
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{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.
| Collector | What you get | Status |
|---|---|---|
devices | Inventory plus online / alerting / offline status | Default |
uplinks | WAN uplink status, loss %, latency, warm-spare HA role | Default |
clients | Client counts, org-wide and per network | Default |
wireless | Channel utilization per AP and band | Default |
licenses | License status and expiry timestamp | Default |
apiusage | Org-wide API response codes (all consumers) | Default |
vpn | Site-to-site VPN peer reachability | Opt-in |
switchports | Port status, admin-enabled, PoE allocation | Opt-in |
sensors | Temperature, humidity, door, water, CO₂, TVOC, noise, battery | Opt-in |
alerts | Open Assurance alerts by severity and category | Opt-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-amd64chmod +x meraki-exporter-linux-amd64# or build from sourceCGO_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.envMERAKI_API_KEY=<read-only-key># MERAKI_ORG_ID=… # only needed if the key sees multiple orgsLISTEN_ADDRESS=:9822SCRAPE_INTERVAL=120sSLOW_SCRAPE_INTERVAL=15mCOLLECTORS_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-exportercurl -s localhost:9822/healthz # "ok" after the first pollcurl -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.ymlscrape_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
clientscollector.
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.
Responses
No comments yet · be the first