tmobile-exporter: vendor minimal Go exporter that handles G4AR quirks

Replaces tpaulus/tmobile-internet-exporter upstream image, which crashes
on every scrape against the G4AR because the gateway returns `bars` as a
JSON float (`4.0`) and the upstream struct types it as int.

The vendored exporter:
- decodes all radio numerics as float64 so `4.0` round-trips
- treats the 4g / 5g signal blocks as optional (the G4AR omits 4g)
- evicts stale (type, band) series when a band disappears between scrapes
- adds a scrape_errors_total counter and /healthz endpoint

Deployment now pulls git.mcintire.me/graham/tmobile-exporter:v1 via the
forgejo-registry imagePullSecret (matching the convention used by aprs,
prop, towerops). Build + push the image before Argo can pull it; the
imagePullSecret must also be copied into the monitoring namespace.
This commit is contained in:
Graham McIntire 2026-05-10 17:42:25 -05:00
parent 20649ba33d
commit f2d6dbd271
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 282 additions and 2 deletions

View file

@ -15,10 +15,12 @@ spec:
labels:
app.kubernetes.io/name: tmobile-exporter
spec:
imagePullSecrets:
- name: forgejo-registry
containers:
- name: exporter
image: ghcr.io/tpaulus/tmobile-internet-exporter:main
imagePullPolicy: Always
image: git.mcintire.me/graham/tmobile-exporter:v1
imagePullPolicy: IfNotPresent
args:
- -target=192.168.12.1
- -listen=:9099

View file

@ -0,0 +1,12 @@
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tmobile-exporter .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/tmobile-exporter /tmobile-exporter
USER 65532:65532
EXPOSE 9099
ENTRYPOINT ["/tmobile-exporter"]

View file

@ -0,0 +1,17 @@
module github.com/gmcintire/infra/tmobile-exporter
go 1.23
require github.com/prometheus/client_golang v1.20.5
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
golang.org/x/sys v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

View file

@ -0,0 +1,24 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=

View file

@ -0,0 +1,225 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const ns = "tmobile_gateway"
type device struct {
Manufacturer string `json:"manufacturer"`
Name string `json:"name"`
Model string `json:"model"`
HardwareVersion string `json:"hardwareVersion"`
SoftwareVersion string `json:"softwareVersion"`
Serial string `json:"serial"`
MacID string `json:"macId"`
Role string `json:"role"`
}
type timeBlock struct {
UpTime int64 `json:"upTime"`
}
// signalBlock is shared by both the 4g and 5g objects. All numeric fields
// are decoded as float64 — the G4AR returns `bars` as a float (e.g. 4.0)
// while older Sagemcom gateways return it as an int, and json.Unmarshal
// into float64 accepts both shapes.
type signalBlock struct {
Cid float64 `json:"cid"`
Sinr float64 `json:"sinr"`
Rsrp float64 `json:"rsrp"`
Rsrq float64 `json:"rsrq"`
Rssi float64 `json:"rssi"`
Bars float64 `json:"bars"`
Bands []string `json:"bands"`
}
type signalGroup struct {
FourG *signalBlock `json:"4g,omitempty"`
FiveG *signalBlock `json:"5g,omitempty"`
}
type gatewayStatus struct {
Device device `json:"device"`
Time timeBlock `json:"time"`
Signal signalGroup `json:"signal"`
}
var (
gatewayUp = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: ns, Name: "gateway_up",
Help: "1 if the last scrape of the gateway succeeded, else 0.",
})
scrapeErrors = promauto.NewCounter(prometheus.CounterOpts{
Namespace: ns, Name: "scrape_errors_total",
Help: "Number of failed gateway scrapes.",
})
uptimeSeconds = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: ns, Name: "uptime_seconds",
Help: "Gateway uptime in seconds.",
}, []string{"serial"})
deviceInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: ns, Name: "device_info",
Help: "Static gateway metadata; value is always 1.",
}, []string{"serial", "manufacturer", "model", "name", "hardware_version", "software_version", "mac"})
radio = map[string]*prometheus.GaugeVec{}
)
func init() {
for _, m := range []string{"channel_id", "rsrp", "rsrq", "rssi", "snr", "bars"} {
radio[m] = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: ns, Name: m,
Help: "Radio " + m + " per access tech / band.",
}, []string{"type", "band"})
}
}
type collector struct {
target string
client *http.Client
mu sync.Mutex
last map[string]map[string]struct{} // type -> set of bands seen
}
func newCollector(target string) *collector {
return &collector{
target: target,
client: &http.Client{Timeout: 5 * time.Second},
last: map[string]map[string]struct{}{"4g": {}, "5g": {}},
}
}
func (c *collector) url() string {
return fmt.Sprintf("http://%s/TMI/v1/gateway?get=all", c.target)
}
func (c *collector) scrape(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url(), nil)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gateway returned %d", resp.StatusCode)
}
var s gatewayStatus
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return fmt.Errorf("decode: %w", err)
}
uptimeSeconds.WithLabelValues(s.Device.Serial).Set(float64(s.Time.UpTime))
deviceInfo.Reset()
deviceInfo.WithLabelValues(
s.Device.Serial, s.Device.Manufacturer, s.Device.Model, s.Device.Name,
s.Device.HardwareVersion, s.Device.SoftwareVersion, s.Device.MacID,
).Set(1)
c.mu.Lock()
defer c.mu.Unlock()
c.observe("4g", s.Signal.FourG)
c.observe("5g", s.Signal.FiveG)
return nil
}
// observe records signal metrics for one access tech (4g or 5g). It also
// drops any (type, band) series that were present on a previous scrape but
// are missing now — otherwise a dropped band would stick around forever.
func (c *collector) observe(tech string, b *signalBlock) {
seen := map[string]struct{}{}
if b != nil {
bands := b.Bands
if len(bands) == 0 {
bands = []string{""}
}
for _, band := range bands {
seen[band] = struct{}{}
radio["channel_id"].WithLabelValues(tech, band).Set(b.Cid)
radio["rsrp"].WithLabelValues(tech, band).Set(b.Rsrp)
radio["rsrq"].WithLabelValues(tech, band).Set(b.Rsrq)
radio["rssi"].WithLabelValues(tech, band).Set(b.Rssi)
radio["snr"].WithLabelValues(tech, band).Set(b.Sinr)
radio["bars"].WithLabelValues(tech, band).Set(b.Bars)
}
}
for band := range c.last[tech] {
if _, ok := seen[band]; !ok {
for _, g := range radio {
g.DeleteLabelValues(tech, band)
}
}
}
c.last[tech] = seen
}
func (c *collector) run(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
if err := c.scrape(ctx); err != nil {
scrapeErrors.Inc()
gatewayUp.Set(0)
log.Printf("scrape failed: %v", err)
} else {
gatewayUp.Set(1)
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func main() {
listen := flag.String("listen", ":9099", "exporter listen address")
target := flag.String("target", "192.168.12.1", "gateway IP or host")
every := flag.Duration("scrape-frequency", 10*time.Second, "how often to poll the gateway")
flag.Parse()
ctx, cancel := context.WithCancel(context.Background())
sigC := make(chan os.Signal, 1)
signal.Notify(sigC, syscall.SIGINT, syscall.SIGTERM)
go func() { <-sigC; cancel() }()
c := newCollector(*target)
go c.run(ctx, *every)
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
})
srv := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Printf("tmobile-exporter listening on %s, target=%s", *listen, *target)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
_ = srv.Shutdown(shutdownCtx)
}