// SPDX-FileCopyrightText: 2024 Stefan Schärmeli <schaermu@pm.me>
// SPDX-License-Identifier: MIT
package cdio
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/schaermu/changedetection.io-exporter/pkg/data"
log "github.com/sirupsen/logrus"
)
type ApiClient struct {
Client *http.Client
baseUrl string
key string
}
func NewApiClient(baseUrl string, key string) *ApiClient {
return &ApiClient{
Client: &http.Client{},
baseUrl: fmt.Sprintf("%s/api/v1", baseUrl),
key: key,
}
}
func NewTestApiClient(url string) *ApiClient {
return NewApiClient(url, "foo-bar-key")
}
func (client *ApiClient) SetBaseUrl(baseUrl string) {
client.baseUrl = fmt.Sprintf("%s/api/v1", baseUrl)
}
func (client *ApiClient) getRequest(method string, url string, body io.Reader) (*http.Request, error) {
targetUrl := fmt.Sprintf("%s/%s", client.baseUrl, url)
log.Debugf("curl \"%s\" -H\"x-api-key:%s\"", targetUrl, client.key)
req, err := http.NewRequest(method, targetUrl, body)
if err != nil {
log.Error(err)
return nil, err
}
req.Header.Add("x-api-key", client.key)
return req, nil
}
func (client *ApiClient) GetWatches() (map[string]*data.WatchItem, error) {
req, err := client.getRequest("GET", "watch", nil)
if err != nil {
return nil, err
}
res, err := client.Client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
watches := make(map[string]*data.WatchItem)
err = json.NewDecoder(res.Body).Decode(&watches)
if err != nil {
return nil, err
}
return watches, nil
}
func (client *ApiClient) GetWatchData(id string) (*data.WatchItem, error) {
req, err := client.getRequest("GET", fmt.Sprintf("watch/%s", id), nil)
if err != nil {
return nil, err
}
res, err := client.Client.Do(req)
if err != nil {
return nil, err
}
switch res.StatusCode {
case 404:
// watch not found, was probably removed
return nil, fmt.Errorf("watch %s not found", id)
}
defer res.Body.Close()
var watchItem = data.WatchItem{}
err = json.NewDecoder(res.Body).Decode(&watchItem)
if err != nil {
return nil, err
}
return &watchItem, nil
}
func (client *ApiClient) GetLatestPriceSnapshot(id string) (*data.PriceData, error) {
req, err := client.getRequest("GET", fmt.Sprintf("watch/%s/history/latest", id), nil)
if err != nil {
return nil, err
}
res, err := client.Client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode == 404 {
// watch not found, was probably removed
return nil, fmt.Errorf("watch %s not found", id)
}
defer res.Body.Close()
bodyText, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
var priceData = data.PriceData{}
err = json.Unmarshal(bodyText, &priceData)
if err != nil {
// check if the error is due to the response being an array
if err.Error() == "json: cannot unmarshal array into Go value of type data.PriceData" {
var priceDataArray []data.PriceData
err = json.Unmarshal(bodyText, &priceDataArray)
if err != nil {
log.Error(err)
return nil, err
}
return &priceDataArray[0], nil
}
log.Error(err)
return nil, err
}
return &priceData, nil
}
func (client *ApiClient) GetSystemInfo() (*data.SystemInfo, error) {
req, err := client.getRequest("GET", "systeminfo", nil)
if err != nil {
return nil, err
}
res, err := client.Client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var systemInfo = data.SystemInfo{}
err = json.NewDecoder(res.Body).Decode(&systemInfo)
if err != nil {
return nil, err
}
return &systemInfo, nil
}
// SPDX-FileCopyrightText: 2024 Stefan Schärmeli <schaermu@pm.me>
// SPDX-License-Identifier: MIT
package collectors
import (
"sync"
"github.com/schaermu/changedetection.io-exporter/pkg/cdio"
)
var (
namespace = "changedetectionio"
labels = []string{"title", "source"}
)
type baseCollector struct {
sync.RWMutex
ApiClient *cdio.ApiClient
}
func newBaseCollector(client *cdio.ApiClient) *baseCollector {
return &baseCollector{
ApiClient: client,
}
}
// SPDX-FileCopyrightText: 2024 Stefan Schärmeli <schaermu@pm.me>
// SPDX-License-Identifier: MIT
package collectors
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/schaermu/changedetection.io-exporter/pkg/cdio"
log "github.com/sirupsen/logrus"
)
type priceCollector struct {
baseCollector
price *prometheus.Desc
}
func NewPriceCollector(client *cdio.ApiClient) *priceCollector {
return &priceCollector{
baseCollector: *newBaseCollector(client),
price: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "watch", "price"),
"Current price of an offer type watch",
labels, nil,
),
}
}
func (c *priceCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.price
}
func (c *priceCollector) Collect(ch chan<- prometheus.Metric) {
c.RLock()
defer c.RUnlock()
// check for new watches before collecting metrics
watches, err := c.ApiClient.GetWatches()
if err != nil {
log.Errorf("error while fetching watches: %v", err)
}
for uuid, watch := range watches {
// get latest price snapshot
if pData, err := c.ApiClient.GetLatestPriceSnapshot(uuid); err == nil {
if metricLabels, err := watch.GetMetrics(); err != nil {
log.Error(err)
continue
} else {
ch <- prometheus.MustNewConstMetric(c.price, prometheus.GaugeValue, pData.Price, metricLabels...)
}
} else {
log.Error(err)
}
}
}
// SPDX-FileCopyrightText: 2024 Stefan Schärmeli <schaermu@pm.me>
// SPDX-License-Identifier: MIT
package collectors
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/schaermu/changedetection.io-exporter/pkg/cdio"
log "github.com/sirupsen/logrus"
)
type systemCollector struct {
baseCollector
queueSize *prometheus.Desc
overdueCount *prometheus.Desc
uptime *prometheus.Desc
watchCount *prometheus.Desc
}
func NewSystemCollector(client *cdio.ApiClient) *systemCollector {
return &systemCollector{
baseCollector: *newBaseCollector(client),
queueSize: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "system", "queue_size"),
"Current changedetection.io instance queue size",
[]string{"version"}, nil,
),
watchCount: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "system", "watch_count"),
"Current changedetection.io instance watch count",
[]string{"version"}, nil,
),
overdueCount: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "system", "overdue_watch_count"),
"Current changedetection.io instance overdue watch count",
[]string{"version"}, nil,
),
uptime: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "system", "uptime"),
"Current changedetection.io instance system uptime",
[]string{"version"}, nil,
),
}
}
func (c *systemCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.queueSize
ch <- c.watchCount
ch <- c.overdueCount
ch <- c.uptime
}
func (c *systemCollector) Collect(ch chan<- prometheus.Metric) {
c.RLock()
defer c.RUnlock()
// check for new watches before collecting metrics
system, err := c.ApiClient.GetSystemInfo()
if err != nil {
log.Errorf("error while fetching system info: %v", err)
} else {
ch <- prometheus.MustNewConstMetric(c.queueSize, prometheus.GaugeValue, float64(system.QueueSize), system.Version)
ch <- prometheus.MustNewConstMetric(c.watchCount, prometheus.GaugeValue, float64(system.WatchCount), system.Version)
ch <- prometheus.MustNewConstMetric(c.overdueCount, prometheus.GaugeValue, float64(len(system.OverdueWatches)), system.Version)
ch <- prometheus.MustNewConstMetric(c.uptime, prometheus.GaugeValue, float64(system.Uptime), system.Version)
}
}
// SPDX-FileCopyrightText: 2024 Stefan Schärmeli <schaermu@pm.me>
// SPDX-License-Identifier: MIT
package collectors
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/schaermu/changedetection.io-exporter/pkg/cdio"
log "github.com/sirupsen/logrus"
)
type watchCollector struct {
baseCollector
checkCount *prometheus.Desc
fetchTime *prometheus.Desc
notificationAlertCount *prometheus.Desc
lastCheckStatus *prometheus.Desc
}
func NewWatchCollector(client *cdio.ApiClient) *watchCollector {
return &watchCollector{
baseCollector: *newBaseCollector(client),
checkCount: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "watch", "check_count"),
"Number of checks for a watch",
labels, nil,
),
fetchTime: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "watch", "fetch_time"),
"Time it took to fetch the watch",
labels, nil,
),
notificationAlertCount: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "watch", "notification_alert_count"),
"Number of notification alerts for a watch",
labels, nil,
),
lastCheckStatus: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "watch", "last_check_status"),
"Status of the last check for a watch",
labels, nil,
),
}
}
func (c *watchCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.checkCount
ch <- c.fetchTime
ch <- c.notificationAlertCount
ch <- c.lastCheckStatus
}
func (c *watchCollector) Collect(ch chan<- prometheus.Metric) {
c.RLock()
defer c.RUnlock()
// check for new watches before collecting metrics
watches, err := c.ApiClient.GetWatches()
if err != nil {
log.Errorf("error while fetching watches: %v", err)
}
for uuid := range watches {
// get latest watch data
if watchData, err := c.ApiClient.GetWatchData(uuid); err == nil {
if metricLabels, err := watchData.GetMetrics(); err != nil {
log.Error(err)
continue
} else {
ch <- prometheus.MustNewConstMetric(c.checkCount, prometheus.CounterValue, float64(watchData.CheckCount), metricLabels...)
ch <- prometheus.MustNewConstMetric(c.fetchTime, prometheus.GaugeValue, watchData.FetchTime, metricLabels...)
ch <- prometheus.MustNewConstMetric(c.notificationAlertCount, prometheus.CounterValue, float64(watchData.NotificationAlertCount), metricLabels...)
ch <- prometheus.MustNewConstMetric(c.lastCheckStatus, prometheus.GaugeValue, float64(watchData.LastCheckStatus), metricLabels...)
}
} else {
log.Error(err)
}
}
}
// SPDX-FileCopyrightText: 2024 Stefan Schärmeli <schaermu@pm.me>
// SPDX-License-Identifier: MIT
package data
import (
"fmt"
"net/url"
)
type StringBoolean bool
func (sb *StringBoolean) UnmarshalJSON(data []byte) error {
if string(data) == "false" {
*sb = false
} else {
*sb = true
}
return nil
}
type WatchItem struct {
LastChanged int64 `json:"last_changed"`
LastChecked int64 `json:"last_checked"`
LastError StringBoolean `json:"last_error"`
Title string `json:"title"`
Url string `json:"url"`
CheckCount int `json:"check_count,omitempty"`
FetchTime float64 `json:"fetch_time,omitempty"`
NotificationAlertCount int `json:"notification_alert_count,omitempty"`
LastCheckStatus int `json:"last_check_status,omitempty"`
PriceData *PriceData `json:"price,omitempty"`
}
type PriceData struct {
Price float64 `json:"price"`
Currency string `json:"priceCurrency"`
Availability string `json:"availability"`
}
type SystemInfo struct {
Version string `json:"version"`
Uptime float64 `json:"uptime"`
WatchCount int `json:"watch_count"`
OverdueWatches []string `json:"overdue_watches"`
QueueSize int `json:"queue_size"`
}
func (w *WatchItem) GetMetrics() ([]string, error) {
url, err := url.ParseRequestURI(w.Url)
if err != nil {
return nil, err
} else if url.Host == "" {
return nil, fmt.Errorf("host is empty")
}
if w.Title == "" {
return nil, fmt.Errorf("title is empty")
}
return []string{w.Title, url.Host}, nil
}