Monitoring gpsd with Prometheus using prometheus-gpsd-exporter

6 min read

GPS receivers spit out NMEA, gpsd parses it, and getting that data into Prometheus shouldn’t require babysitting a crashy exporter. Yet here we are.

The existing options for gpsd-to-Prometheus are a Python exporter and a Go exporter. Both have the same fundamental problem: they don’t stay running. The Python one relies on the gps library, which throws StopIteration when the connection hiccups — unhandled, fatal, exporter dead. The Go one polls on a 10-second interval instead of streaming, calls log.Fatal on parse errors, has no reconnect logic, and uses different metric names from the Python exporter — so your Grafana dashboards break if you switch between them.

You end up restarting gpsd, restarting the exporter, and wondering why your satellite count graph has gaps every few hours.

A Rust rewrite that stays up

prometheus-gpsd-exporter replaces both. It’s a single async Rust binary — Tokio runtime, one persistent TCP connection to gpsd with streaming JSON, one HTTP server on /metrics. The architecture is simple:

gpsd (host A:2947) --TCP/JSON--> exporter (host B) --HTTP/metrics--> Prometheus

Direct TCP connection, no fragile library dependency. It reads gpsd’s JSON stream, parses every message type that matters — TPV, SKY, GST, TOFF, PPS, OSC — and exposes them as Prometheus metrics. Unknown or malformed messages get logged and skipped. Never crashes.

When the connection drops, it reconnects with exponential backoff — starting at 10 seconds, capping at 5 minutes. No manual intervention, no systemd restart loops, no gaps in your graphs.

Getting gpsd to cooperate

A solid exporter is half the battle. The other half is getting gpsd to actually emit the data you want. Two things trip people up here: baud rate and network listening.

The baud rate problem

gpsd truncates SKY messages and other verbose output unless you tell it the correct baud rate for your receiver. Most modern GPS modules run at 115200 baud. Without the -s flag, gpsd defaults to 9600 and silently drops data — you get position fixes but no satellite details, no DOP values, no signal strength per satellite.

Traditional Linux (/etc/default/gpsd):

GPSD_OPTIONS="-G -s 115200"

NixOS:

services.gpsd = {
  enable = true;
  devices = [ "/dev/ttyUSB0" ];
  listenany = true;  # equivalent of -G
  extraArgs = [ "-s" "115200" ];
};

The -G flag (or listenany in NixOS) tells gpsd to listen on all interfaces instead of just localhost. You’ll need this if the exporter runs on a different host than the GPS receiver.

Making gpsd listen on the network

On traditional Linux distributions, gpsd uses systemd socket activation. The default socket only listens on localhost. To expose gpsd over the network, you need to override the socket unit.

Traditional Linux (systemctl edit gpsd.socket):

[Socket]
ListenStream=
ListenStream=/var/run/gpsd.sock
ListenStream=[::]:2947
ListenStream=0.0.0.0:2947

The empty ListenStream= clears the defaults first — without it, you’d be adding listeners on top of the existing ones. Then the subsequent lines add back the Unix socket plus all-interface TCP listeners. After editing, restart both units:

sudo systemctl restart gpsd.socket gpsd.service

NixOS:

The NixOS gpsd module doesn’t use socket activation — it runs a forking service directly. The listenany = true option handles network listening. If you need socket activation with both Unix and TCP sockets for some reason, define it explicitly:

systemd.sockets.gpsd = {
  description = "GPS Daemon Sockets";
  wantedBy = [ "sockets.target" ];
  socketConfig = {
    ListenStream = [
      "/run/gpsd.sock"
      "[::]:2947"
      "0.0.0.0:2947"
    ];
    SocketMode = "0600";
  };
};

Most NixOS setups won’t need this — listenany = true is sufficient when the exporter and gpsd are on the same host or when you just need TCP access.

Setting up the exporter

As a Nix flake

Add the exporter as a flake input and import the NixOS module:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    prometheus-gpsd-exporter.url = "github:ijohanne/prometheus-gpsd-exporter";
  };

  outputs = { nixpkgs, prometheus-gpsd-exporter, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        prometheus-gpsd-exporter.nixosModules.default
        ./configuration.nix
      ];
    };
  };
}

NixOS module configuration

services.prometheus-gpsd-exporter = {
  enable = true;
  enableLocalScraping = true;
  gpsdHost = "192.168.1.100";
  ppsHistogram = true;
  offsetFromGeopoint = true;
  geopointLat = 51.5074;
  geopointLon = -0.1278;
};

enableLocalScraping adds a Prometheus scrape target automatically — no manual scrapeConfigs editing. gpsdHost points at whichever machine runs your GPS receiver. The geopoint options enable geo-offset histograms — set them to your receiver’s actual coordinates. You can read the initial values from the gpsd_lat and gpsd_long metrics once the exporter is running, then pin them in your config.

Standalone binary

Not on NixOS? The binary works anywhere:

prometheus-gpsd-exporter \
  --hostname <gpsd-host> \
  --port 2947 \
  --exporter-port 9015 \
  --listen-address :: \
  --pps-histogram

CLI reference

-H, --hostname <HOST>       gpsd host [default: localhost]
-p, --port <PORT>           gpsd port [default: 2947]
-E, --exporter-port <PORT>  metrics port [default: 9015]
-L, --listen-address <ADDR> listen address [default: ::]
-t, --timeout <SECS>        connection timeout [default: 10]
--retry-delay <SECS>        initial retry delay [default: 10]
--max-retry-delay <SECS>    max retry delay [default: 300]
-S, --disable-monitor-satellites
--pps-histogram             enable PPS offset histograms
--pps-bucket-size <NS>      [default: 250]
--pps-bucket-count <N>      [default: 40]
--pps-time1 <FLOAT>         PPS time1 offset [default: 0]
--offset-from-geopoint      enable geo-offset tracking
--geopoint-lat <FLOAT>
--geopoint-lon <FLOAT>
--geo-bucket-size <M>       [default: 0.5]
--geo-bucket-count <N>      [default: 40]

The PPS options are for precision timing setups — if you’re running an NTP server disciplined by a GPS PPS signal, the histograms show pulse offset distribution. The geo-offset options track how far your reported position drifts from a known fixed point, which is useful for evaluating antenna placement or receiver quality.

What metrics you get

The exporter covers every gpsd message type that carries useful data:

  • TPV — latitude, longitude, altitude, speed, track, climb, and all the error estimates (epx, epy, epv, ept, eps, epc). The core positioning data.
  • SKY — DOP values (gdop, hdop, pdop, tdop, vdop, xdop, ydop) and satellite counts. High DOP means bad geometry — you want these low.
  • Per-satellite — signal strength, azimuth, elevation, and health status. Labeled by PRN, svid, gnssid, sigid, freqid, and whether the satellite is used in the fix. This is the data that tells you if your antenna has a clear sky view.
  • GST — pseudorange noise and error ellipse data. The statistical quality of your position solution.
  • TOFF — kernel-vs-GPS time offset. Essential if you’re running NTP with a GPS reference clock.
  • PPS — pulse-per-second timing data, with optional histograms for offset distribution.
  • OSC — oscillator status: running, reference, disciplined, delta. For GPSDO setups.
  • Info — gpsd version and connected device metadata.

Metric names are compatible with the Python exporter (gpsd_lat, gpsd_hdop, gpsd_satellites_used, etc.), so existing Grafana dashboards work without changes. Switching from the Python exporter is a drop-in replacement.

Why this over the alternatives

Python [1]Go [2]Rust [3]
Crash resilientnonoyes
Streamingyesnoyes
Reconnectbuggynoyes
No runtime depsnoyesyes
Dashboard compatyesnoyes
GSTnoyesyes
TOFFnoyesyes
OSCnoyesyes
PPS histogramsyesnoyes
Geo-offsetyesnoyes
Per-sat labelsyespartialyes
Per-sat sigidnoyesyes

[1] brendanbank/gpsd-prometheus-exporter
[2] natesales/gpsd-exporter
[3] ijohanne/prometheus-gpsd-exporter

The Python exporter is the one most people start with. It has the right metric names and decent coverage, but the gps library dependency is a liability — StopIteration crashes are a known issue with no upstream fix. The Go exporter covers more message types but uses polling instead of streaming, crashes on parse errors, and renames every metric so your dashboards need rewriting.

The Rust exporter takes the superset of both feature sets and adds the one thing neither has: reliability. It stays connected, reconnects when it can’t, and never exits on bad data.

Wrapping up

The setup is a flake input, a module configuration, and making sure gpsd is configured to actually emit the data you want — which means getting the baud rate right and, if the exporter runs remotely, opening up the network socket. Once that’s wired, your GPS receiver’s position accuracy, satellite coverage, timing offsets, and signal quality all live in Prometheus alongside everything else. The project is on GitHub — contributions welcome.