diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c282cc5..89d0f65 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,7 +4,7 @@ on: push: branches: - master - - http + - prowlarr paths-ignore: - 'assets/**' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 59ef95a..d6a5101 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,7 +4,7 @@ on: push: branches-ignore: - master - - http + - prowlarr pull_request_target: jobs: diff --git a/.mockery.yaml b/.mockery.yaml index a109062..56c6fa6 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -17,6 +17,9 @@ packages: github.com/clambin/mediamon/v2/internal/collectors/xxxarr: interfaces: Client: + github.com/clambin/mediamon/v2/internal/collectors/prowlarr: + interfaces: + Client: github.com/clambin/mediamon/v2/internal/collectors/xxxarr/clients: interfaces: RadarrClient: diff --git a/go.mod b/go.mod index 345fdcc..529847f 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/clambin/go-common/charmer v0.2.0 github.com/clambin/go-common/http v0.5.0 github.com/clambin/go-common/set v0.4.3 - github.com/clambin/mediaclients v0.4.2 + github.com/clambin/mediaclients v0.5.0 github.com/prometheus/client_golang v1.19.1 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 diff --git a/go.sum b/go.sum index ae6cc46..be61475 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/clambin/go-common/set v0.4.3 h1:Sm9lkAJsh82j40RDpfQIziHyHjwr07+KsQF6v github.com/clambin/go-common/set v0.4.3/go.mod h1:Q5GpBoM7B7abNV2Wzys+wQMInBHMoHyh/h0Cn2OmY4A= github.com/clambin/go-common/testutils v0.1.0 h1:/nGWaOCIhW+Ew1c2NU7GLY/YPb8dp9SV8+MTgWksAgk= github.com/clambin/go-common/testutils v0.1.0/go.mod h1:bV0j8D4zhNkleCeluFKLBeLQ0L/dqkxbaR/joLn8kzg= -github.com/clambin/mediaclients v0.4.2 h1:9d46sKGTJRTE4gHXeRthfHCsGZmCbJtPZjeCS4hOcSc= -github.com/clambin/mediaclients v0.4.2/go.mod h1:EAjWM31ZMHqV1e92r+n9FWEI6A93vBSEyEfuLnRFpm8= +github.com/clambin/mediaclients v0.5.0 h1:71G5qtAxmcM07JooKv/dWAztCyxLw1pzaG4QFMTjqcs= +github.com/clambin/mediaclients v0.5.0/go.mod h1:EAjWM31ZMHqV1e92r+n9FWEI6A93vBSEyEfuLnRFpm8= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/cmd/mediamon/collectors.go b/internal/cmd/mediamon/collectors.go index b2443d6..9949b52 100644 --- a/internal/cmd/mediamon/collectors.go +++ b/internal/cmd/mediamon/collectors.go @@ -5,6 +5,7 @@ import ( "github.com/clambin/mediamon/v2/internal/collectors/bandwidth" "github.com/clambin/mediamon/v2/internal/collectors/connectivity" "github.com/clambin/mediamon/v2/internal/collectors/plex" + "github.com/clambin/mediamon/v2/internal/collectors/prowlarr" "github.com/clambin/mediamon/v2/internal/collectors/transmission" "github.com/clambin/mediamon/v2/internal/collectors/xxxarr" "github.com/prometheus/client_golang/prometheus" @@ -37,6 +38,12 @@ var constructors = map[string]constructor{ return xxxarr.NewRadarrCollector(url, v.GetString("radarr.apikey"), logger), nil }, }, + "prowlarr.url": { + name: "prowlarr", + make: func(url, _ string, v *viper.Viper, logger *slog.Logger) (prometheus.Collector, error) { + return prowlarr.New(url, v.GetString("prowlarr.apikey"), logger), nil + }, + }, "plex.url": { name: "plex", make: func(url, version string, v *viper.Viper, logger *slog.Logger) (prometheus.Collector, error) { diff --git a/internal/cmd/mediamon/mediamon_test.go b/internal/cmd/mediamon/mediamon_test.go index 76d7b41..95ab00f 100644 --- a/internal/cmd/mediamon/mediamon_test.go +++ b/internal/cmd/mediamon/mediamon_test.go @@ -27,7 +27,7 @@ func TestExecute(t *testing.T) { assert.Eventually(t, func() bool { _, err := http.Get("http://127.0.0.1:9090/metrics") return err == nil - }, time.Second, time.Millisecond*100) + }, 5*time.Second, time.Millisecond*100) assert.NoError(t, testutil.GatherAndCompare( prometheus.DefaultGatherer, diff --git a/internal/collectors/prowlarr/metrics.go b/internal/collectors/prowlarr/metrics.go new file mode 100644 index 0000000..05365f9 --- /dev/null +++ b/internal/collectors/prowlarr/metrics.go @@ -0,0 +1,51 @@ +package prowlarr + +import "github.com/prometheus/client_golang/prometheus" + +func newMetrics(url string) map[string]*prometheus.Desc { + constLabels := prometheus.Labels{"application": "prowlarr", "url": url} + return map[string]*prometheus.Desc{ + "indexerResponseTime": prometheus.NewDesc( + prometheus.BuildFQName("mediamon", "prowlarr", "indexer_response_time"), + "Average response time in seconds", + []string{"indexer"}, + constLabels, + ), + "indexerQueryTotal": prometheus.NewDesc( + prometheus.BuildFQName("mediamon", "prowlarr", "indexer_query_total"), + "Total number of queries to this indexer", + []string{"indexer"}, + constLabels, + ), + "indexerGrabTotal": prometheus.NewDesc( + prometheus.BuildFQName("mediamon", "prowlarr", "indexer_grab_total"), + "Total number of grabs from this indexer", + []string{"indexer"}, + constLabels, + ), + "indexerFailedQueryTotal": prometheus.NewDesc( + prometheus.BuildFQName("mediamon", "prowlarr", "indexer_failed_query_total"), + "Total number of failed queries to this indexer", + []string{"indexer"}, + constLabels, + ), + "indexerFailedGrabTotal": prometheus.NewDesc( + prometheus.BuildFQName("mediamon", "prowlarr", "indexer_failed_grab_total"), + "Total number of failed grabs from this indexer", + []string{"indexer"}, + constLabels, + ), + "userAgentQueryTotal": prometheus.NewDesc( + prometheus.BuildFQName("mediamon", "prowlarr", "user_agent_query_total"), + "Total number of queries by user agent", + []string{"user_agent"}, + constLabels, + ), + "userAgentGrabTotal": prometheus.NewDesc( + prometheus.BuildFQName("mediamon", "prowlarr", "user_agent_grab_total"), + "Total number of grabs by user agent", + []string{"user_agent"}, + constLabels, + ), + } +} diff --git a/internal/collectors/prowlarr/mocks/Client.go b/internal/collectors/prowlarr/mocks/Client.go new file mode 100644 index 0000000..1bffa98 --- /dev/null +++ b/internal/collectors/prowlarr/mocks/Client.go @@ -0,0 +1,94 @@ +// Code generated by mockery v2.43.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + xxxarr "github.com/clambin/mediaclients/xxxarr" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +type Client_Expecter struct { + mock *mock.Mock +} + +func (_m *Client) EXPECT() *Client_Expecter { + return &Client_Expecter{mock: &_m.Mock} +} + +// GetIndexStats provides a mock function with given fields: _a0 +func (_m *Client) GetIndexStats(_a0 context.Context) (xxxarr.ProwlarrIndexersStats, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for GetIndexStats") + } + + var r0 xxxarr.ProwlarrIndexersStats + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (xxxarr.ProwlarrIndexersStats, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(context.Context) xxxarr.ProwlarrIndexersStats); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(xxxarr.ProwlarrIndexersStats) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Client_GetIndexStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIndexStats' +type Client_GetIndexStats_Call struct { + *mock.Call +} + +// GetIndexStats is a helper method to define mock.On call +// - _a0 context.Context +func (_e *Client_Expecter) GetIndexStats(_a0 interface{}) *Client_GetIndexStats_Call { + return &Client_GetIndexStats_Call{Call: _e.mock.On("GetIndexStats", _a0)} +} + +func (_c *Client_GetIndexStats_Call) Run(run func(_a0 context.Context)) *Client_GetIndexStats_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *Client_GetIndexStats_Call) Return(_a0 xxxarr.ProwlarrIndexersStats, _a1 error) *Client_GetIndexStats_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Client_GetIndexStats_Call) RunAndReturn(run func(context.Context) (xxxarr.ProwlarrIndexersStats, error)) *Client_GetIndexStats_Call { + _c.Call.Return(run) + return _c +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/collectors/prowlarr/prowlarr.go b/internal/collectors/prowlarr/prowlarr.go new file mode 100644 index 0000000..b1982a0 --- /dev/null +++ b/internal/collectors/prowlarr/prowlarr.go @@ -0,0 +1,84 @@ +package prowlarr + +import ( + "context" + "github.com/clambin/go-common/http/metrics" + "github.com/clambin/go-common/http/roundtripper" + "github.com/clambin/mediaclients/xxxarr" + collectorbreaker "github.com/clambin/mediamon/v2/pkg/collector-breaker" + "github.com/prometheus/client_golang/prometheus" + "log/slog" + "time" +) + +type Collector struct { + client Client + metrics map[string]*prometheus.Desc + tpMetrics metrics.RequestMetrics + cacheMetrics roundtripper.CacheMetrics + logger *slog.Logger +} + +type Client interface { + GetIndexStats(context.Context) (xxxarr.ProwlarrIndexersStats, error) +} + +func New(url, apiKey string, logger *slog.Logger) *collectorbreaker.CBCollector { + tpMetrics := metrics.NewRequestMetrics(metrics.Options{ + Namespace: "mediamon", + ConstLabels: prometheus.Labels{"application": "prowlarr"}, + }) + cacheMetrics := roundtripper.NewCacheMetrics(roundtripper.CacheMetricsOptions{ + Namespace: "mediamon", + ConstLabels: prometheus.Labels{"application": "prowlarr"}, + }) + + r := roundtripper.New( + roundtripper.WithCache(roundtripper.CacheOptions{ + DefaultExpiration: 15 * time.Minute, + CleanupInterval: time.Hour, + CacheMetrics: cacheMetrics, + }), + roundtripper.WithRequestMetrics(tpMetrics), + ) + c := Collector{ + client: xxxarr.NewProwlarrClient(url, apiKey, r), + metrics: newMetrics(url), + tpMetrics: tpMetrics, + cacheMetrics: cacheMetrics, + logger: logger, + } + return collectorbreaker.New("prowlarr", &c, logger) +} + +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + for _, m := range c.metrics { + ch <- m + } + c.tpMetrics.Describe(ch) + c.cacheMetrics.Describe(ch) +} + +func (c *Collector) CollectE(ch chan<- prometheus.Metric) error { + stats, err := c.client.GetIndexStats(context.Background()) + if err == nil { + for _, indexer := range stats.Indexers { + name := indexer.IndexerName + //c.logger.Debug("indexer found", "indexer", name, "queries", indexer.NumberOfQueries, "grabs", indexer.NumberOfGrabs) + ch <- prometheus.MustNewConstMetric(c.metrics["indexerResponseTime"], prometheus.GaugeValue, time.Duration(indexer.AverageResponseTime).Seconds(), name) + ch <- prometheus.MustNewConstMetric(c.metrics["indexerQueryTotal"], prometheus.CounterValue, float64(indexer.NumberOfQueries), name) + ch <- prometheus.MustNewConstMetric(c.metrics["indexerFailedQueryTotal"], prometheus.CounterValue, float64(indexer.NumberOfFailedQueries), name) + ch <- prometheus.MustNewConstMetric(c.metrics["indexerGrabTotal"], prometheus.CounterValue, float64(indexer.NumberOfGrabs), name) + ch <- prometheus.MustNewConstMetric(c.metrics["indexerFailedGrabTotal"], prometheus.CounterValue, float64(indexer.NumberOfFailedGrabs), name) + } + for _, userAgent := range stats.UserAgents { + agent := userAgent.UserAgent + //c.logger.Debug("user agent found", "agent", agent, "queries", userAgent.NumberOfQueries, "grabs", userAgent.NumberOfGrabs) + ch <- prometheus.MustNewConstMetric(c.metrics["userAgentQueryTotal"], prometheus.CounterValue, float64(userAgent.NumberOfQueries), agent) + ch <- prometheus.MustNewConstMetric(c.metrics["userAgentGrabTotal"], prometheus.CounterValue, float64(userAgent.NumberOfGrabs), agent) + } + } + c.tpMetrics.Collect(ch) + c.cacheMetrics.Collect(ch) + return err +} diff --git a/internal/collectors/prowlarr/prowlarr_test.go b/internal/collectors/prowlarr/prowlarr_test.go new file mode 100644 index 0000000..5acbf56 --- /dev/null +++ b/internal/collectors/prowlarr/prowlarr_test.go @@ -0,0 +1,74 @@ +package prowlarr + +import ( + "context" + "github.com/clambin/mediaclients/xxxarr" + "github.com/clambin/mediamon/v2/internal/collectors/prowlarr/mocks" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "log/slog" + "strings" + "testing" + "time" +) + +func TestCollector(t *testing.T) { + prowlarr := mocks.NewClient(t) + prowlarr.EXPECT().GetIndexStats(context.Background()).Return(xxxarr.ProwlarrIndexersStats{ + Indexers: []xxxarr.ProwlarrIndexerStats{{ + IndexerId: 1, + IndexerName: "foo", + AverageResponseTime: xxxarr.ProwlarrResponseTime(100 * time.Millisecond), + NumberOfQueries: 10, + NumberOfFailedQueries: 1, + NumberOfGrabs: 2, + NumberOfFailedGrabs: 1, + }}, + UserAgents: []xxxarr.ProwlarrUserAgentStats{{ + UserAgent: "foo", + NumberOfQueries: 10, + NumberOfGrabs: 1, + }}, + }, nil) + + c := New("http://localhost", "", slog.Default()) + c.Collector.(*Collector).client = prowlarr + + assert.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(` +# HELP mediamon_prowlarr_indexer_failed_grab_total Total number of failed grabs from this indexer +# TYPE mediamon_prowlarr_indexer_failed_grab_total counter +mediamon_prowlarr_indexer_failed_grab_total{application="prowlarr",indexer="foo",url="http://localhost"} 1 + +# HELP mediamon_prowlarr_indexer_failed_query_total Total number of failed queries to this indexer +# TYPE mediamon_prowlarr_indexer_failed_query_total counter +mediamon_prowlarr_indexer_failed_query_total{application="prowlarr",indexer="foo",url="http://localhost"} 1 + +# HELP mediamon_prowlarr_indexer_grab_total Total number of grabs from this indexer +# TYPE mediamon_prowlarr_indexer_grab_total counter +mediamon_prowlarr_indexer_grab_total{application="prowlarr",indexer="foo",url="http://localhost"} 2 + +# HELP mediamon_prowlarr_indexer_query_total Total number of queries to this indexer +# TYPE mediamon_prowlarr_indexer_query_total counter +mediamon_prowlarr_indexer_query_total{application="prowlarr",indexer="foo",url="http://localhost"} 10 + +# HELP mediamon_prowlarr_indexer_response_time Average response time in seconds +# TYPE mediamon_prowlarr_indexer_response_time gauge +mediamon_prowlarr_indexer_response_time{application="prowlarr",indexer="foo",url="http://localhost"} 0.1 + +# HELP mediamon_prowlarr_user_agent_grab_total Total number of grabs by user agent +# TYPE mediamon_prowlarr_user_agent_grab_total counter +mediamon_prowlarr_user_agent_grab_total{application="prowlarr",url="http://localhost",user_agent="foo"} 1 + +# HELP mediamon_prowlarr_user_agent_query_total Total number of queries by user agent +# TYPE mediamon_prowlarr_user_agent_query_total counter +mediamon_prowlarr_user_agent_query_total{application="prowlarr",url="http://localhost",user_agent="foo"} 10 +`), + "mediamon_prowlarr_indexer_grab_total", + "mediamon_prowlarr_indexer_query_total", + "mediamon_prowlarr_indexer_failed_grab_total", + "mediamon_prowlarr_indexer_failed_query_total", + "mediamon_prowlarr_indexer_response_time", + "mediamon_prowlarr_user_agent_query_total", + "mediamon_prowlarr_user_agent_grab_total", + )) +}