diff options
| author | Martin Fischer <martin@push-f.com> | 2026-01-17 20:07:17 +0100 |
|---|---|---|
| committer | Martin Fischer <martin@push-f.com> | 2026-01-22 17:56:20 +0100 |
| commit | b5e9076c6e1683e4d2384ac76e85ad1503c5336a (patch) | |
| tree | ad7cb44bb28c2266597f6dfc2e9dac7687f49aa3 | |
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | LICENSE | 19 | ||||
| -rw-r--r-- | README.md | 19 | ||||
| -rw-r--r-- | default.nix | 8 | ||||
| -rw-r--r-- | go.mod | 31 | ||||
| -rw-r--r-- | go.sum | 83 | ||||
| -rw-r--r-- | hashes.nix | 3 | ||||
| -rw-r--r-- | main.go | 144 | ||||
| -rw-r--r-- | main_test.go | 90 | ||||
| -rw-r--r-- | service.nix | 35 |
10 files changed, 433 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4a847d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/result @@ -0,0 +1,19 @@ +Copyright (c) 2026 Martin Fischer <martin@push-f.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6611111 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# web-feed-exporter + +Prometheus metrics for the most recently published timestamp of web feeds +(RSS, Atom or JSON), so your Grafana dashboard can remind you to post +to your blog or social media. + +## Provided metrics + +The following metrics are provided. All of them have a `feed` label containing the feed name. + +* `webfeed_items`, how many items the feed contains +* `webfeed_newest_published_timestamp_seconds`, Unix timestamp of the newest published item +* `webfeed_oldest_published_timestamp_seconds`, Unix timestamp of the oldest published item (that is still in the feed) + +## Limitations + +Feeds often contain only a limited number of the most recent items. +This exporter does not store anything so it also provides no metrics +for how many items were published in total or in specific time frames. diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..61416cd --- /dev/null +++ b/default.nix @@ -0,0 +1,8 @@ +{ pkgs ? import <nixpkgs> {} }: + +pkgs.buildGoModule { + pname = "web-feed-exporter"; + version = "0+git"; + src = pkgs.lib.cleanSource ./.; + vendorHash = (import ./hashes.nix).vendor; +} @@ -0,0 +1,31 @@ +module push-f.com/web-feed-exporter + +go 1.25.4 + +require ( + github.com/alecthomas/kong v1.13.0 + github.com/google/go-cmp v0.7.0 + github.com/mmcdole/gofeed v1.3.0 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/common v0.66.1 +) + +require ( + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect +) @@ -0,0 +1,83 @@ +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= +github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/hashes.nix b/hashes.nix new file mode 100644 index 0000000..3ecd26a --- /dev/null +++ b/hashes.nix @@ -0,0 +1,3 @@ +{ + vendor = "sha256-xNwlFPf8yg3Rbe9MWH05XNapyA1CFhhzy4jEB1VxGGQ="; +} @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "math" + "net/http" + "net/url" + "os" + + "github.com/alecthomas/kong" + "github.com/mmcdole/gofeed" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type Args struct { + Port int `help:"Port to listen on" short:"p" default:"8888" env:"PORT"` + Feeds StringMap `placeholder:"{\"name\": \"url\", ...}" required:"" help:"Feeds to fetch" env:"FEEDS"` +} + +type StringMap map[string]string + +func (p *StringMap) UnmarshalJSON(data []byte) error { + stringMap := (*map[string]string)(p) + return json.Unmarshal(data, &stringMap) +} + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + slog.SetDefault(logger) + + var args Args + ctx := kong.Parse(&args, kong.Description( + "Prometheus metrics for the most/least recently published timestamp of web feeds (RSS, Atom or JSON).", + )) + + te, err := newExporter(args.Feeds, prometheus.DefaultRegisterer) + ctx.FatalIfErrorf(err) + + prometheus.MustRegister(te) + http.Handle("/metrics", promhttp.Handler()) + + slog.Info("starting", "urls", args.Feeds) + slog.Info(fmt.Sprintf("serving metrics at http://localhost:%d/metrics", args.Port)) + http.ListenAndServe(fmt.Sprintf(":%d", args.Port), nil) +} + +type Exporter struct { + urls map[string]string + itemCount *prometheus.Desc + newestPublishedTimestampSeconds *prometheus.Desc + oldestPublishedTimestampSeconds *prometheus.Desc +} + +func newExporter(feeds map[string]string, reg prometheus.Registerer) (*Exporter, error) { + if len(feeds) == 0 { + return nil, fmt.Errorf("provided feeds map is empty") + } + + for _, urlStr := range feeds { + url, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + if url.Scheme != "http" && url.Scheme != "https" { + return nil, fmt.Errorf("unsupported protocol scheme %q (only http and https are supported)", url.Scheme) + } + } + + return &Exporter{ + urls: feeds, + itemCount: prometheus.NewDesc( + "webfeed_items", + "How many items the feed contains", + []string{"feed"}, + nil, + ), + newestPublishedTimestampSeconds: prometheus.NewDesc( + "webfeed_newest_published_timestamp_seconds", + "Unix timestamp of the newest published item", + []string{"feed"}, + nil, + ), + oldestPublishedTimestampSeconds: prometheus.NewDesc( + "webfeed_oldest_published_timestamp_seconds", + "Unix timestamp of the oldest published item (that is still in the feed)", + []string{"feed"}, + nil, + ), + }, nil +} + +func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { + ch <- e.itemCount + ch <- e.newestPublishedTimestampSeconds + ch <- e.oldestPublishedTimestampSeconds +} + +func (e *Exporter) Collect(ch chan<- prometheus.Metric) { + for name, url := range e.urls { + e.collectFeed(name, url, ch) + } +} + +func (e *Exporter) collectFeed(name, url string, ch chan<- prometheus.Metric) { + feed, err := fetchFeed(url) + if err != nil { + slog.Error("failed to fetch feed", "url", url, "error", err) + return + } + + ch <- prometheus.MustNewConstMetric(e.itemCount, prometheus.GaugeValue, float64(len(feed.Items)), name) + + if len(feed.Items) == 0 { + return + } + + newestPublished := int64(-1) + oldestPublished := int64(math.MaxInt64) + + for _, item := range feed.Items { + if item.PublishedParsed != nil { + if item.PublishedParsed.Unix() > int64(newestPublished) { + newestPublished = item.PublishedParsed.Unix() + } + if item.PublishedParsed.Unix() < int64(oldestPublished) { + oldestPublished = item.PublishedParsed.Unix() + } + } + } + + ch <- prometheus.MustNewConstMetric(e.newestPublishedTimestampSeconds, prometheus.GaugeValue, float64(newestPublished), name) + ch <- prometheus.MustNewConstMetric(e.oldestPublishedTimestampSeconds, prometheus.GaugeValue, float64(oldestPublished), name) +} + +var fetchFeed = func(url string) (*gofeed.Feed, error) { + fp := gofeed.NewParser() + fp.UserAgent = "web-feed-exporter" + return fp.ParseURL(url) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..112798e --- /dev/null +++ b/main_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "log" + "os" + "path" + "slices" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/mmcdole/gofeed" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/expfmt" +) + +func TestGrepRepo(t *testing.T) { + reposDir := t.TempDir() + err := os.Mkdir(path.Join(reposDir, "project1"), 0o755) + if err != nil { + t.Fatal(err) + } + + feedUrl1 := "http://example.com/feed1.xml" + feedUrl2 := "http://example.com/feed2.xml" + + urls := map[string]string{"feed1": feedUrl1, "feed2": feedUrl2} + reg := prometheus.NewRegistry() + te, err := newExporter(urls, reg) + if err != nil { + t.Fatal(err) + } + reg.MustRegister(te) + + latestTimes := map[string][]int64{ + feedUrl1: {1700000000, 1900000000, 1300000000}, + feedUrl2: {1500000000}, + } + + fetchFeed = func(url string) (*gofeed.Feed, error) { + items := []*gofeed.Item{} + for _, sec := range latestTimes[url] { + time := time.Unix(sec, 0) + items = append(items, &gofeed.Item{ + PublishedParsed: &time, + }) + } + return &gofeed.Feed{ + Items: items, + }, nil + } + + want := "# HELP webfeed_items How many items the feed contains\n" + + "# TYPE webfeed_items gauge\n" + + fmt.Sprintf("webfeed_items{feed=\"feed1\"} %d\n", len(latestTimes[feedUrl1])) + + fmt.Sprintf("webfeed_items{feed=\"feed2\"} %d\n", len(latestTimes[feedUrl2])) + + "# HELP webfeed_newest_published_timestamp_seconds Unix timestamp of the newest published item\n" + + "# TYPE webfeed_newest_published_timestamp_seconds gauge\n" + + fmt.Sprintf("webfeed_newest_published_timestamp_seconds{feed=\"feed1\"} %.1e\n", float64(slices.Max(latestTimes[feedUrl1]))) + + fmt.Sprintf("webfeed_newest_published_timestamp_seconds{feed=\"feed2\"} %.1e\n", float64(slices.Max(latestTimes[feedUrl2]))) + + "# HELP webfeed_oldest_published_timestamp_seconds Unix timestamp of the oldest published item (that is still in the feed)\n" + + "# TYPE webfeed_oldest_published_timestamp_seconds gauge\n" + + fmt.Sprintf("webfeed_oldest_published_timestamp_seconds{feed=\"feed1\"} %.1e\n", float64(slices.Min(latestTimes[feedUrl1]))) + + fmt.Sprintf("webfeed_oldest_published_timestamp_seconds{feed=\"feed2\"} %.1e\n", float64(slices.Min(latestTimes[feedUrl2]))) + + if diff := cmp.Diff(collectMetrics(reg), want); diff != "" { + t.Errorf("unexpected metrics: %v", diff) + } +} + +func collectMetrics(reg *prometheus.Registry) string { + mfs, done, err := prometheus.ToTransactionalGatherer(reg).Gather() + if err != nil { + log.Fatal(err) + } + defer done() + + var buf strings.Builder + enc := expfmt.NewEncoder(&buf, expfmt.NewFormat(expfmt.TypeTextPlain)) + + for _, mf := range mfs { + err := enc.Encode(mf) + if err != nil { + log.Fatal(err) + } + } + return buf.String() +} diff --git a/service.nix b/service.nix new file mode 100644 index 0000000..1942107 --- /dev/null +++ b/service.nix @@ -0,0 +1,35 @@ +{ config, lib, pkgs, ... }: + +let + web_feed_exporter = pkgs.callPackage ./default.nix {}; + cfg = config.services.webFeedExporter; +in +{ + options.services.webFeedExporter = { + enable = lib.mkEnableOption "web-feed-exporter"; + + port = lib.mkOption { + type = lib.types.int; + }; + + feeds = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + description = "Map of feed names to feed URLs."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.web-feed-exporter = { + serviceConfig = { + DynamicUser = true; + ExecStart = "${web_feed_exporter}/bin/web-feed-exporter"; + }; + environment = { + PORT = toString cfg.port; + FEEDS = builtins.toJSON cfg.feeds; + }; + + wantedBy = ["multi-user.target"]; + }; + }; +} |
