From 91d140bc09476d0cea932115d0639644900dc4de Mon Sep 17 00:00:00 2001 From: Daniel Innes Date: Sat, 20 Jun 2026 13:19:35 -0400 Subject: [PATCH 1/2] init ghealth --- flake.nix | 1 + modules/google-health-exporter.nix | 160 +++++++ modules/google-health-exporter/go.mod | 41 ++ modules/google-health-exporter/go.sum | 104 +++++ modules/google-health-exporter/main.go | 586 +++++++++++++++++++++++++ 5 files changed, 892 insertions(+) create mode 100644 modules/google-health-exporter.nix create mode 100644 modules/google-health-exporter/go.mod create mode 100644 modules/google-health-exporter/go.sum create mode 100644 modules/google-health-exporter/main.go diff --git a/flake.nix b/flake.nix index b3f73c7..e730340 100644 --- a/flake.nix +++ b/flake.nix @@ -76,6 +76,7 @@ nixos-facter-modules.nixosModules.facter ./modules/airzone-exporter.nix ./modules/openweathermap-exporter.nix + ./modules/google-health-exporter.nix ./modules/konnected-exporter.nix ./modules/bookorbit.nix home-manager.nixosModules.home-manager diff --git a/modules/google-health-exporter.nix b/modules/google-health-exporter.nix new file mode 100644 index 0000000..cbacf9d --- /dev/null +++ b/modules/google-health-exporter.nix @@ -0,0 +1,160 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.prometheus.exporters.google-health; + + package = pkgs.buildGoModule { + pname = "google-health-exporter"; + version = "0.1.0"; + src = ./google-health-exporter; + vendorHash = "sha256-GIrf/p/5sxODTjSuOgvthZWI488XduMrHC339b8AiQU="; + }; +in +{ + options.services.prometheus.exporters.google-health = { + enable = lib.mkEnableOption "Google Health Prometheus exporter"; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Address to bind the exporter to."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9878; + description = "Port to bind the exporter to."; + }; + + credentialsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to a Google credentials JSON file. If null, Application Default Credentials are used."; + }; + + credentialsType = lib.mkOption { + type = lib.types.enum [ + "authorized-user" + "service-account" + "impersonated-service-account" + "external-account" + ]; + default = "authorized-user"; + description = "Type of Google credentials JSON file to load when credentialsFile is set."; + }; + + dataTypes = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "steps:daily" + "distance:daily" + "active-energy-burned:daily" + "total-calories:daily" + ]; + description = "Google Health data types to roll up. Each item may include a :daily or :physical suffix."; + }; + + defaultRollupMode = lib.mkOption { + type = lib.types.enum [ + "daily" + "physical" + ]; + default = "daily"; + description = "Default rollup mode for data types without an explicit suffix."; + }; + + refreshInterval = lib.mkOption { + type = lib.types.str; + default = "10m"; + description = "How often the exporter refreshes data from Google Health."; + }; + + requestTimeout = lib.mkOption { + type = lib.types.str; + default = "30s"; + description = "HTTP timeout for each Google Health refresh."; + }; + + lookback = lib.mkOption { + type = lib.types.str; + default = "24h"; + description = "How far back each Google Health rollup request should query."; + }; + + physicalWindow = lib.mkOption { + type = lib.types.str; + default = "1h"; + description = "Aggregation window for physical-time rollups."; + }; + + dailyWindowDays = lib.mkOption { + type = lib.types.ints.positive; + default = 1; + description = "Aggregation window size in days for daily rollups."; + }; + + dataSourceFamily = lib.mkOption { + type = lib.types.str; + default = "all-sources"; + description = "Google Health data source family to roll up."; + }; + + pageSize = lib.mkOption { + type = lib.types.ints.positive; + default = 10000; + description = "Maximum rollup data points to request per page."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.prometheus-google-health-exporter = { + description = "Google Health Prometheus exporter"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + DynamicUser = true; + ExecStart = lib.escapeShellArgs ( + [ + "${package}/bin/nixos-google-health-exporter" + "--web.host=${cfg.host}" + "--web.port=${toString cfg.port}" + "--data-types=${lib.concatStringsSep "," cfg.dataTypes}" + "--default-rollup-mode=${cfg.defaultRollupMode}" + "--refresh-interval=${cfg.refreshInterval}" + "--request-timeout=${cfg.requestTimeout}" + "--lookback=${cfg.lookback}" + "--physical-window=${cfg.physicalWindow}" + "--daily-window-days=${toString cfg.dailyWindowDays}" + "--data-source-family=${cfg.dataSourceFamily}" + "--page-size=${toString cfg.pageSize}" + ] + ++ lib.optionals (cfg.credentialsFile != null) [ + "--credentials-file=%d/credentials" + "--credentials-type=${cfg.credentialsType}" + ] + ); + LoadCredential = lib.optional (cfg.credentialsFile != null) "credentials:${cfg.credentialsFile}"; + Restart = "on-failure"; + RestartSec = 5; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + LockPersonality = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + SystemCallArchitectures = "native"; + }; + }; + }; +} diff --git a/modules/google-health-exporter/go.mod b/modules/google-health-exporter/go.mod new file mode 100644 index 0000000..a362258 --- /dev/null +++ b/modules/google-health-exporter/go.mod @@ -0,0 +1,41 @@ +module github.com/daniel-w-innes/nixos-google-health-exporter + +go 1.25.9 + +require ( + github.com/prometheus/client_golang v1.23.2 + google.golang.org/api v0.285.0 +) + +require ( + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/modules/google-health-exporter/go.sum b/modules/google-health-exporter/go.sum new file mode 100644 index 0000000..e4664db --- /dev/null +++ b/modules/google-health-exporter/go.sum @@ -0,0 +1,104 @@ +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.285.0 h1:B7eHHoKGAX/LrPkQvhQqnGwjgWxofbdGwCTQvpm8FkM= +google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/google-health-exporter/main.go b/modules/google-health-exporter/main.go new file mode 100644 index 0000000..fed4229 --- /dev/null +++ b/modules/google-health-exporter/main.go @@ -0,0 +1,586 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "math" + "net" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "google.golang.org/api/health/v4" + "google.golang.org/api/option" +) + +const namespace = "google_health" + +var nonMetricChars = regexp.MustCompile(`[^a-zA-Z0-9_]+`) + +type rollupMode string + +const ( + rollupModePhysical rollupMode = "physical" + rollupModeDaily rollupMode = "daily" +) + +type dataTypeConfig struct { + DataType string + Mode rollupMode +} + +type sample struct { + DataType string + Mode rollupMode + Field string + StartTime string + EndTime string + Value float64 +} + +type exporter struct { + client *health.Service + dataTypes []dataTypeConfig + interval time.Duration + requestTimeout time.Duration + lookback time.Duration + physicalWindow time.Duration + dailyWindowDays int64 + dataSourceFamily string + pageSize int64 + logger *log.Logger + + mu sync.RWMutex + lastUpdate time.Time + lastAttempt time.Time + lastError string + scrapeOK bool + samples []sample +} + +func newExporter(client *health.Service, dataTypes []dataTypeConfig, interval time.Duration, requestTimeout time.Duration, lookback time.Duration, physicalWindow time.Duration, dailyWindowDays int64, dataSourceFamily string, pageSize int64, logger *log.Logger) *exporter { + return &exporter{ + client: client, + dataTypes: dataTypes, + interval: interval, + requestTimeout: requestTimeout, + lookback: lookback, + physicalWindow: physicalWindow, + dailyWindowDays: dailyWindowDays, + dataSourceFamily: dataSourceFamily, + pageSize: pageSize, + logger: logger, + } +} + +func (e *exporter) Run() { + e.refresh() + + ticker := time.NewTicker(e.interval) + defer ticker.Stop() + + for range ticker.C { + e.refresh() + } +} + +func (e *exporter) refresh() { + e.mu.Lock() + defer e.mu.Unlock() + e.lastAttempt = time.Now() + + ctx, cancel := context.WithTimeout(context.Background(), e.requestTimeout) + defer cancel() + + samples, err := e.collectSamples(ctx, e.lastAttempt) + if err != nil { + e.scrapeOK = false + e.lastError = err.Error() + e.logger.Printf("refresh failed: %v", err) + return + } + + e.samples = samples + e.lastUpdate = time.Now() + e.scrapeOK = true + e.lastError = "" +} + +func (e *exporter) collectSamples(ctx context.Context, now time.Time) ([]sample, error) { + var samples []sample + + for _, cfg := range e.dataTypes { + var ( + points []rollupPoint + err error + ) + + switch cfg.Mode { + case rollupModePhysical: + points, err = e.physicalRollup(ctx, cfg.DataType, now) + case rollupModeDaily: + points, err = e.dailyRollup(ctx, cfg.DataType, now) + default: + return nil, fmt.Errorf("unsupported rollup mode %q for data type %q", cfg.Mode, cfg.DataType) + } + if err != nil { + return nil, fmt.Errorf("%s %s rollup: %w", cfg.DataType, cfg.Mode, err) + } + + for _, point := range points { + values, err := flattenRollupValues(point.Raw) + if err != nil { + return nil, fmt.Errorf("%s %s flatten: %w", cfg.DataType, cfg.Mode, err) + } + for _, value := range values { + samples = append(samples, sample{ + DataType: cfg.DataType, + Mode: cfg.Mode, + Field: value.Field, + StartTime: point.StartTime, + EndTime: point.EndTime, + Value: value.Value, + }) + } + } + } + + return samples, nil +} + +type rollupPoint struct { + Raw any + StartTime string + EndTime string +} + +func (e *exporter) physicalRollup(ctx context.Context, dataType string, now time.Time) ([]rollupPoint, error) { + req := &health.RollUpDataPointsRequest{ + DataSourceFamily: e.dataSourceFamily, + PageSize: e.pageSize, + Range: &health.Interval{ + StartTime: now.Add(-e.lookback).UTC().Format(time.RFC3339Nano), + EndTime: now.UTC().Format(time.RFC3339Nano), + }, + WindowSize: formatGoogleDuration(e.physicalWindow), + } + + parent := fmt.Sprintf("users/me/dataTypes/%s", dataType) + var points []rollupPoint + err := e.client.Users.DataTypes.DataPoints.RollUp(parent, req).Pages(ctx, func(resp *health.RollUpDataPointsResponse) error { + for _, point := range resp.RollupDataPoints { + points = append(points, rollupPoint{ + Raw: point, + StartTime: point.StartTime, + EndTime: point.EndTime, + }) + } + return nil + }) + return points, err +} + +func (e *exporter) dailyRollup(ctx context.Context, dataType string, now time.Time) ([]rollupPoint, error) { + start := now.Add(-e.lookback) + req := &health.DailyRollUpDataPointsRequest{ + DataSourceFamily: e.dataSourceFamily, + PageSize: e.pageSize, + Range: &health.CivilTimeInterval{ + Start: civilDate(start), + End: civilDate(now.AddDate(0, 0, 1)), + }, + WindowSizeDays: e.dailyWindowDays, + } + + parent := fmt.Sprintf("users/me/dataTypes/%s", dataType) + resp, err := e.client.Users.DataTypes.DataPoints.DailyRollUp(parent, req).Context(ctx).Do() + if err != nil { + return nil, err + } + + points := make([]rollupPoint, 0, len(resp.RollupDataPoints)) + for _, point := range resp.RollupDataPoints { + points = append(points, rollupPoint{ + Raw: point, + StartTime: formatCivilDateTime(point.CivilStartTime), + EndTime: formatCivilDateTime(point.CivilEndTime), + }) + } + return points, nil +} + +func civilDate(t time.Time) *health.CivilDateTime { + t = t.Local() + return &health.CivilDateTime{ + Date: &health.Date{ + Year: int64(t.Year()), + Month: int64(t.Month()), + Day: int64(t.Day()), + }, + } +} + +func formatCivilDateTime(dt *health.CivilDateTime) string { + if dt == nil || dt.Date == nil { + return "" + } + return fmt.Sprintf("%04d-%02d-%02d", dt.Date.Year, dt.Date.Month, dt.Date.Day) +} + +func (e *exporter) Describe(ch chan<- *prometheus.Desc) { + prometheus.DescribeByCollect(e, ch) +} + +func (e *exporter) Collect(ch chan<- prometheus.Metric) { + e.mu.RLock() + defer e.mu.RUnlock() + + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "up"), + "Whether the last Google Health refresh succeeded.", + nil, + nil, + ), + prometheus.GaugeValue, + boolFloat(e.scrapeOK), + ) + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "last_refresh_timestamp"), + "Unix timestamp in milliseconds of the last successful Google Health refresh.", + nil, + nil, + ), + prometheus.GaugeValue, + float64(e.lastUpdate.UnixMilli()), + ) + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "last_refresh_attempt_timestamp"), + "Unix timestamp in milliseconds of the last attempted Google Health refresh.", + nil, + nil, + ), + prometheus.GaugeValue, + float64(e.lastAttempt.UnixMilli()), + ) + + if !e.scrapeOK { + return + } + + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "rollup_samples"), + "Number of Google Health rollup samples currently exported.", + nil, + nil, + ), + prometheus.GaugeValue, + float64(len(e.samples)), + ) + + desc := prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "rollup_value"), + "Numeric value returned by a Google Health rollup.", + []string{"data_type", "rollup_mode", "field", "start_time", "end_time"}, + nil, + ) + for _, sample := range e.samples { + ch <- prometheus.MustNewConstMetric( + desc, + prometheus.GaugeValue, + sample.Value, + sample.DataType, + string(sample.Mode), + sample.Field, + sample.StartTime, + sample.EndTime, + ) + } +} + +type metricValue struct { + Field string + Value float64 +} + +func flattenRollupValues(v any) ([]metricValue, error) { + content, err := json.Marshal(v) + if err != nil { + return nil, err + } + + var decoded map[string]any + if err := json.Unmarshal(content, &decoded); err != nil { + return nil, err + } + + for _, field := range []string{"startTime", "endTime", "civilStartTime", "civilEndTime"} { + delete(decoded, field) + } + + var values []metricValue + flattenJSON("", decoded, &values) + return values, nil +} + +func flattenJSON(prefix string, v any, values *[]metricValue) { + switch typed := v.(type) { + case map[string]any: + for key, value := range typed { + name := sanitizeMetricPart(key) + if prefix != "" { + name = prefix + "_" + name + } + flattenJSON(name, value, values) + } + case []any: + for i, value := range typed { + flattenJSON(fmt.Sprintf("%s_%d", prefix, i), value, values) + } + case float64: + if !math.IsNaN(typed) && !math.IsInf(typed, 0) { + *values = append(*values, metricValue{Field: prefix, Value: typed}) + } + case bool: + *values = append(*values, metricValue{Field: prefix, Value: boolFloat(typed)}) + case string: + if number, ok := parseNumericString(typed); ok { + *values = append(*values, metricValue{Field: prefix, Value: number}) + } else if seconds, ok := parseGoogleDuration(typed); ok { + *values = append(*values, metricValue{Field: prefix + "_seconds", Value: seconds}) + } + } +} + +func sanitizeMetricPart(s string) string { + s = camelToSnake(s) + s = nonMetricChars.ReplaceAllString(s, "_") + return strings.Trim(s, "_") +} + +func camelToSnake(s string) string { + var out strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + out.WriteByte('_') + } + out.WriteRune(r) + } + return strings.ToLower(out.String()) +} + +func formatGoogleDuration(d time.Duration) string { + seconds := d.Seconds() + if seconds == math.Trunc(seconds) { + return fmt.Sprintf("%.0fs", seconds) + } + return fmt.Sprintf("%.9fs", seconds) +} + +func parseGoogleDuration(s string) (float64, bool) { + if !strings.HasSuffix(s, "s") { + return 0, false + } + seconds, err := strconv.ParseFloat(strings.TrimSuffix(s, "s"), 64) + return seconds, err == nil +} + +func parseNumericString(s string) (float64, bool) { + if strings.TrimSpace(s) != s || s == "" { + return 0, false + } + number, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsNaN(number) || math.IsInf(number, 0) { + return 0, false + } + return number, true +} + +func boolFloat(v bool) float64 { + if v { + return 1 + } + return 0 +} + +func parseDataTypes(raw string, defaultMode rollupMode) ([]dataTypeConfig, error) { + if strings.TrimSpace(raw) == "" { + return nil, errors.New("at least one data type is required") + } + + var configs []dataTypeConfig + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + cfg := dataTypeConfig{ + DataType: part, + Mode: defaultMode, + } + if before, after, ok := strings.Cut(part, ":"); ok { + cfg.DataType = strings.TrimSpace(before) + cfg.Mode = rollupMode(strings.TrimSpace(after)) + } + if cfg.DataType == "" { + return nil, fmt.Errorf("invalid data type %q", part) + } + if cfg.Mode != rollupModePhysical && cfg.Mode != rollupModeDaily { + return nil, fmt.Errorf("invalid rollup mode %q for data type %q", cfg.Mode, cfg.DataType) + } + configs = append(configs, cfg) + } + if len(configs) == 0 { + return nil, errors.New("at least one data type is required") + } + return configs, nil +} + +func normalizeDataSourceFamily(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "users/") { + return raw + } + return "users/me/dataSourceFamilies/" + raw +} + +func parseCredentialsType(raw string) (option.CredentialsType, error) { + switch strings.TrimSpace(raw) { + case "authorized-user", "authorized_user": + return option.AuthorizedUser, nil + case "service-account", "service_account": + return option.ServiceAccount, nil + case "impersonated-service-account", "impersonated_service_account": + return option.ImpersonatedServiceAccount, nil + case "external-account", "external_account": + return option.ExternalAccount, nil + default: + return "", fmt.Errorf("unsupported credentials type %q", raw) + } +} + +func main() { + var ( + host = flag.String("web.host", "127.0.0.1", "Host or IP address to listen on for Prometheus scrapes.") + port = flag.String("web.port", "9878", "TCP port to listen on for Prometheus scrapes.") + credentialsFile = flag.String("credentials-file", "", "File containing Google credentials JSON.") + credentialsType = flag.String("credentials-type", "authorized-user", "Credential file type. Valid values: authorized-user, service-account, impersonated-service-account, external-account.") + dataTypesFlag = flag.String("data-types", "steps:daily,distance:daily,active-energy-burned:daily,total-calories:daily", "Comma-separated Google Health data types to roll up. Append :daily or :physical to override the default rollup mode for an item.") + defaultModeFlag = flag.String("default-rollup-mode", string(rollupModeDaily), "Default rollup mode for data types without an explicit suffix. Valid values: daily, physical.") + refresh = flag.Duration("refresh-interval", 10*time.Minute, "Refresh interval used to poll Google Health.") + requestTimeout = flag.Duration("request-timeout", 30*time.Second, "Timeout for each Google Health refresh.") + lookback = flag.Duration("lookback", 24*time.Hour, "How far back each Google Health rollup request should query.") + physicalWindow = flag.Duration("physical-window", time.Hour, "Aggregation window for physical rollups.") + dailyWindowDays = flag.Int64("daily-window-days", 1, "Aggregation window size in days for daily rollups.") + dataSourceFamily = flag.String("data-source-family", "all-sources", "Google Health data source family to roll up, or empty for the API default.") + pageSize = flag.Int64("page-size", 10000, "Maximum rollup data points to request per page.") + ) + + flag.Parse() + + defaultMode := rollupMode(*defaultModeFlag) + if defaultMode != rollupModeDaily && defaultMode != rollupModePhysical { + log.Fatalf("invalid --default-rollup-mode %q", *defaultModeFlag) + } + + if *dailyWindowDays < 1 { + log.Fatal("--daily-window-days must be at least 1") + } + + if *refresh <= 0 { + log.Fatal("--refresh-interval must be greater than 0") + } + + if *requestTimeout <= 0 { + log.Fatal("--request-timeout must be greater than 0") + } + + if *lookback <= 0 { + log.Fatal("--lookback must be greater than 0") + } + + if *physicalWindow <= 0 { + log.Fatal("--physical-window must be greater than 0") + } + + if *pageSize < 1 { + log.Fatal("--page-size must be at least 1") + } + + dataTypes, err := parseDataTypes(*dataTypesFlag, defaultMode) + if err != nil { + log.Fatalf("parsing data types: %v", err) + } + + ctx := context.Background() + opts := []option.ClientOption{ + option.WithScopes( + health.GooglehealthActivityAndFitnessReadonlyScope, + health.GooglehealthEcgReadonlyScope, + health.GooglehealthHealthMetricsAndMeasurementsReadonlyScope, + health.GooglehealthIrnReadonlyScope, + health.GooglehealthLocationReadonlyScope, + health.GooglehealthProfileReadonlyScope, + health.GooglehealthSettingsReadonlyScope, + health.GooglehealthSleepReadonlyScope, + ), + } + if *credentialsFile != "" { + credType, err := parseCredentialsType(*credentialsType) + if err != nil { + log.Fatalf("parsing credentials type: %v", err) + } + opts = append(opts, option.WithAuthCredentialsFile(credType, *credentialsFile)) + } + + client, err := health.NewService(ctx, opts...) + if err != nil { + log.Fatalf("creating Google Health client: %v", err) + } + + logger := log.New(os.Stdout, "google-health-exporter: ", log.LstdFlags) + exp := newExporter( + client, + dataTypes, + *refresh, + *requestTimeout, + *lookback, + *physicalWindow, + *dailyWindowDays, + normalizeDataSourceFamily(*dataSourceFamily), + *pageSize, + logger, + ) + + go exp.Run() + + registry := prometheus.NewRegistry() + registry.MustRegister(exp) + + http.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) + http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + + listenAddress := net.JoinHostPort(*host, *port) + logger.Printf("listening on %s", listenAddress) + log.Fatal(http.ListenAndServe(listenAddress, nil)) +} From 043dee9f261eab15fb8668d14112da484d3a8a79 Mon Sep 17 00:00:00 2001 From: Daniel Innes Date: Mon, 29 Jun 2026 17:59:39 -0400 Subject: [PATCH 2/2] credentials file --- generic/server/visibility.nix | 10 ++++++++++ secrets/google-oauth2.age | 26 ++++++++++++++++++++++++++ secrets/secrets.nix | 4 ++++ 3 files changed, 40 insertions(+) create mode 100644 secrets/google-oauth2.age diff --git a/generic/server/visibility.nix b/generic/server/visibility.nix index 0ea220b..9df02f6 100644 --- a/generic/server/visibility.nix +++ b/generic/server/visibility.nix @@ -90,6 +90,12 @@ group = "grafana"; mode = "0400"; }; + google-oauth2 = lib.mkIf config.services.prometheus.exporters.google-health.enable { + file = secretsDir + /google-oauth2.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; }; services = { @@ -543,6 +549,10 @@ gotifyTokenPath = config.age.secrets.konnected-gotify-token.path; gotifyAllowList = "Frontdoor,Backdoor"; }; + google-health = { + enable = true; + credentialsFile = config.age.secrets.google-oauth2.path; + }; }; }; influxdb2 = { diff --git a/secrets/google-oauth2.age b/secrets/google-oauth2.age new file mode 100644 index 0000000..560735b --- /dev/null +++ b/secrets/google-oauth2.age @@ -0,0 +1,26 @@ +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IHQ5eDFKUSBWNWhJ +L3psa0gxVk53cWh5dXREUXh5QlMyWmFKVnYrU0VKOEk1R0RJNnhFCmpJVzFpNWY3 +d3QyM00rYzN0dlFDeHkrRE1QUk4rTnQ3ejhPNkoxMzN6TnMKLT4gc3NoLWVkMjU1 +MTkgWU1UNVVnIGoycU94aTV0T20weHJndEwveGxvSVlrNnFSQWM5eko1a0JjWk5T +a2lIQkUKWGdHQzUxVlVqdU1qWTVuc1ZkejdWdnR3ZDdVbjNSQTQvN3ZnNGRMblhi +MAotPiBzc2gtZWQyNTUxOSBkOEhIV3cgUUJLcE4xa0t0RHBqc2N5RzdpNmgrL2Zv +VnBwUXdIYlZkSXBYSVA0cEFIQQpTVFFjaEc2akJGcnBRWVlxQXNjQm1ENisvSVYz +N0dXQnhHYjFXT2NNZUdjCi0+IHNzaC1lZDI1NTE5IHpqYVVZUSArUE1mNHRmQ2pm +dUxJOVp0MEthWVE3bFh1UDVMK3h0MmU4YzAraElKRTFZCkNnZ1QvSEdZdWJyaVhD +dVROS24wVjZVdkdoY1lKZy9CNUpjNWhZa2QvRjQKLT4gc3NoLWVkMjU1MTkgQlNC +SVF3IEUrNmNRTnpTdDlLcFFyVGh1UjBJRlFIRVAyMFJtOEZlR3pvR3Q0MDE5d0EK +eDgvSHhoME52MW1OalpnR3o5dk1lQTluR0l1ZEd4d3JoT05rMm5TaW0yOAotPiBz +c2gtZWQyNTUxOSB6M2MzaHcgREo3RlRLNlh3VHVXbzc3Z2g3S0VnMDVVNFFoS3pB +Y1EvaTlGb3p5Z25sZwplZ2FxUUVDRzVhc2pvN25sWGNWM29rd0VkSXhIWFNlS3Qv +ay9md3dta0QwCi0+IDFzYDMtZ3JlYXNlCkFkUVlsaXNZVmNBZ29COVB5aEVVRWtC +TFpJb1l2b3VwVDhXY2FlUGtyKzdHaVdvdDRnKy82RUpFTHd6awotLS0gbzVIZTBM +dm9tYTJXRkgwcDE2NXJZbWhXMGtRaTREUDlOYUpxSFFHMkg2cwqu2+JN5n4e6/Cs +R48uNRMAuno1IkqFXcAPgQ3PvqSGQcxBh2Detia56qITy9p+JeAl1rfWYkWMqSzX +e7heZgm03zgIj/rZQFgDNRAQEI6I9cCifhZxcghvNmmuXgeooF5Xv4o/j3JzsX/R +v/tnbMmeIS84w+mMXTU3VLiL9iigNHp8iWAqJaQhAC3yWoOQJgash8638FpfkjNV +ZIYp4suzviCukYVjXLFosjSIksjHd0co4l+DgW9cnn68cQzqk6XvkI+o6NtNOWol +Yfo8ybadThaijMSnw2WYnB+tTncpQBVCM1eymPj51gcmrRLynHKmw3ySllNT9j59 +mdX2KDGuSfJ4PZ/8ehRBqZAbkWbI/C4TQo6OKOwKze5uf6GpctUon6+i0slnEP3n +eIdFgLJAiMSE1DA1DnzT7v2rzgYS+ImInXEG4eAtZ46ZQ6WSWh2mQKo= +-----END AGE ENCRYPTED FILE----- diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 6dbdbde..bd044ff 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -134,4 +134,8 @@ in publicKeys = users ++ systems; armor = true; }; + "google-oauth2.age" = { + publicKeys = users ++ systems; + armor = true; + }; }