Initial Release

This commit is contained in:
Philipp Holzer 2024-10-24 20:26:41 +02:00
commit 76ec4ba39f
Signed by: nupplaPhil
GPG key ID: 24A7501396EB5432
31 changed files with 3023 additions and 0 deletions

62
internal/client/client.go Normal file
View file

@ -0,0 +1,62 @@
package client
import (
"crypto/tls"
"errors"
"fmt"
"friendica-exporter/serverinfo"
"net/http"
"time"
)
var (
ErrNotAuthorized = errors.New("wrong credentials")
ErrRatelimit = errors.New("too many requests")
)
type InfoClient func() (*serverinfo.ServerInfo, error)
func New(statsURL string, timeout time.Duration, userAgent string, tlsSkipVerify bool) InfoClient {
client := &http.Client{
Timeout: timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
// disable TLS certification verification, if desired
InsecureSkipVerify: tlsSkipVerify,
},
},
}
return func() (*serverinfo.ServerInfo, error) {
req, err := http.NewRequest(http.MethodGet, statsURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
switch res.StatusCode {
case http.StatusOK:
break
case http.StatusUnauthorized:
return nil, ErrNotAuthorized
case http.StatusTooManyRequests:
return nil, ErrRatelimit
default:
return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
status, err := serverinfo.ParseJSON(res.Body)
if err != nil {
return nil, fmt.Errorf("can not parse server info: %w", err)
}
return status, nil
}
}

View file

@ -0,0 +1,97 @@
package client
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"friendica-exporter/internal/testutil"
"friendica-exporter/serverinfo"
"github.com/google/go-cmp/cmp"
)
func TestClient(t *testing.T) {
wantUserAgent := "test-ua"
tt := []struct {
desc string
handler func(t *testing.T) http.Handler
wantInfo *serverinfo.ServerInfo
wantErr error
}{
{
desc: "statstoken",
handler: func(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, "{}")
})
},
wantInfo: &serverinfo.ServerInfo{},
wantErr: nil,
},
{
desc: "user-agent",
handler: func(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ua := req.UserAgent()
if ua != wantUserAgent {
t.Errorf("got user-agent %q, want %q", ua, wantUserAgent)
}
fmt.Fprintln(w, "{}")
})
},
wantInfo: &serverinfo.ServerInfo{},
wantErr: nil,
},
{
desc: "parse error",
handler: func(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
})
},
wantInfo: nil,
wantErr: errors.New("can not parse server info: EOF"),
},
{
desc: "ratelimit",
handler: func(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
})
},
wantErr: ErrRatelimit,
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.desc, func(t *testing.T) {
t.Parallel()
s := httptest.NewServer(tc.handler(t))
defer s.Close()
client := New(s.URL, time.Second, wantUserAgent, false)
info, err := client()
if !testutil.EqualErrorMessage(err, tc.wantErr) {
t.Errorf("got error %q, want %q", err, tc.wantErr)
}
if err != nil {
return
}
if diff := cmp.Diff(info, tc.wantInfo); diff != "" {
t.Errorf("info differs: -got+want\n%s", diff)
}
})
}
}

229
internal/config/config.go Normal file
View file

@ -0,0 +1,229 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/spf13/pflag"
"gopkg.in/yaml.v3"
)
const (
envPrefix = "FRIENDICA_"
envListenAddress = envPrefix + "LISTEN_ADDRESS"
envTimeout = envPrefix + "TIMEOUT"
envServerURL = envPrefix + "SERVER"
envStatsToken = envPrefix + "STATS_TOKEN"
envTLSSkipVerify = envPrefix + "TLS_SKIP_VERIFY"
)
// RunMode signals what the main application should do after parsing the options.
type RunMode int
const (
// RunModeExporter is the normal operation as an exporter serving metrics via HTTP.
RunModeExporter RunMode = iota
// RunModeHelp shows information about available options.
RunModeHelp
// RunModeVersion shows version information.
RunModeVersion
)
func (m RunMode) String() string {
switch m {
case RunModeExporter:
return "exporter"
case RunModeHelp:
return "help"
case RunModeVersion:
return "version"
default:
return "error"
}
}
// Config contains the configuration options for friendica-exporter.
type Config struct {
ListenAddr string `yaml:"listenAddress"`
Timeout time.Duration `yaml:"timeout"`
ServerURL string `yaml:"server"`
StatsToken string `yaml:"statsToken"`
TLSSkipVerify bool `yaml:"tlsSkipVerify"`
RunMode RunMode
}
var (
errValidateNoServerURL = errors.New("need to set a server URL")
errValidateNoStatsToken = errors.New("need to provide a stats token")
)
// Validate checks if the configuration contains all necessary parameters.
func (c Config) Validate() error {
if len(c.ServerURL) == 0 {
return errValidateNoServerURL
}
if len(c.StatsToken) == 0 {
return errValidateNoStatsToken
}
return nil
}
// Get loads the configuration. Flags, environment variables and configuration file are considered.
func Get() (Config, error) {
return parseConfig(os.Args, os.Getenv)
}
func parseConfig(args []string, envFunc func(string) string) (Config, error) {
result, configFile, err := loadConfigFromFlags(args)
if err != nil {
return Config{}, fmt.Errorf("error parsing flags: %w", err)
}
if configFile != "" {
rawFile, err := loadConfigFromFile(configFile)
if err != nil {
return Config{}, fmt.Errorf("error reading configuration file: %w", err)
}
result = mergeConfig(result, rawFile)
}
env, err := loadConfigFromEnv(envFunc)
if err != nil {
return Config{}, fmt.Errorf("error reading environment variables: %w", err)
}
result = mergeConfig(result, env)
if strings.HasPrefix(result.StatsToken, "@") {
fileName := strings.TrimPrefix(result.StatsToken, "@")
statsToken, err := readStatsTokenFile(fileName)
if err != nil {
return Config{}, fmt.Errorf("can not read stats token file: %w", err)
}
result.StatsToken = statsToken
}
return result, nil
}
func defaultConfig() Config {
return Config{
ListenAddr: ":9205",
Timeout: 5 * time.Second,
}
}
func loadConfigFromFlags(args []string) (result Config, configFile string, err error) {
defaults := defaultConfig()
flags := pflag.NewFlagSet(args[0], pflag.ContinueOnError)
flags.StringVarP(&configFile, "config-file", "c", "", "Path to YAML configuration file.")
flags.StringVarP(&result.ListenAddr, "addr", "a", defaults.ListenAddr, "Address to listen on for connections.")
flags.DurationVarP(&result.Timeout, "timeout", "t", defaults.Timeout, "Timeout for getting server info document.")
flags.StringVarP(&result.ServerURL, "server", "s", "", "URL to Friendica server.")
flags.StringVarP(&result.StatsToken, "stats-token", "u", defaults.StatsToken, "Token for statistics Endpoint of Friendica.")
flags.BoolVar(&result.TLSSkipVerify, "tls-skip-verify", defaults.TLSSkipVerify, "Skip certificate verification of Friendica server.")
modeVersion := flags.BoolP("version", "V", false, "Show version information and exit.")
if err := flags.Parse(args[1:]); err != nil {
if err == pflag.ErrHelp {
return Config{
RunMode: RunModeHelp,
}, "", nil
}
return Config{}, "", err
}
if *modeVersion {
return Config{
RunMode: RunModeVersion,
}, "", nil
}
return result, configFile, nil
}
func loadConfigFromFile(fileName string) (Config, error) {
file, err := os.Open(fileName)
if err != nil {
return Config{}, err
}
var result Config
if err := yaml.NewDecoder(file).Decode(&result); err != nil {
return Config{}, err
}
return result, nil
}
func loadConfigFromEnv(getEnv func(string) string) (Config, error) {
tlsSkipVerify := false
if rawValue := getEnv(envTLSSkipVerify); rawValue != "" {
value, err := strconv.ParseBool(rawValue)
if err != nil {
return Config{}, fmt.Errorf("can not parse value for %q: %s", envTLSSkipVerify, rawValue)
}
tlsSkipVerify = value
}
result := Config{
ListenAddr: getEnv(envListenAddress),
ServerURL: getEnv(envServerURL),
StatsToken: getEnv(envStatsToken),
TLSSkipVerify: tlsSkipVerify,
}
if raw := getEnv(envTimeout); raw != "" {
value, err := time.ParseDuration(raw)
if err != nil {
return Config{}, err
}
result.Timeout = value
}
return result, nil
}
func mergeConfig(base, override Config) Config {
result := base
if override.ListenAddr != "" {
result.ListenAddr = override.ListenAddr
}
if override.ServerURL != "" {
result.ServerURL = override.ServerURL
}
if override.StatsToken != "" {
result.StatsToken = override.StatsToken
}
if override.Timeout != 0 {
result.Timeout = override.Timeout
}
if override.TLSSkipVerify {
result.TLSSkipVerify = override.TLSSkipVerify
}
return result
}
func readStatsTokenFile(fileName string) (string, error) {
bytes, err := os.ReadFile(fileName)
if err != nil {
return "", err
}
return strings.TrimSuffix(string(bytes), "\n"), nil
}

View file

@ -0,0 +1,300 @@
package config
import (
"errors"
"testing"
"time"
"friendica-exporter/internal/testutil"
"github.com/google/go-cmp/cmp"
)
func testEnv(env map[string]string) func(string) string {
return func(key string) string {
return env[key]
}
}
func TestConfig(t *testing.T) {
defaults := defaultConfig()
tt := []struct {
desc string
args []string
env map[string]string
wantErr error
wantConfig Config
}{
{
desc: "flags",
args: []string{
"test",
"--addr",
"127.0.0.1:9205",
"--timeout",
"30s",
"--server",
"http://localhost",
"--stats-token",
"/$&/bdgersert6756!",
},
env: map[string]string{},
wantErr: nil,
wantConfig: Config{
ListenAddr: "127.0.0.1:9205",
Timeout: 30 * time.Second,
ServerURL: "http://localhost",
StatsToken: "/$&/bdgersert6756!",
TLSSkipVerify: false,
},
},
{
desc: "password from file",
args: []string{
"test",
"--server",
"http://localhost",
"--stats-token",
"@testdata/statsToken",
},
env: map[string]string{},
wantErr: nil,
wantConfig: Config{
ListenAddr: defaults.ListenAddr,
Timeout: defaults.Timeout,
ServerURL: "http://localhost",
StatsToken: "234563twtwewr",
TLSSkipVerify: false,
},
},
{
desc: "config from file",
args: []string{
"test",
"--config-file",
"testdata/all.yml",
},
env: map[string]string{},
wantErr: nil,
wantConfig: Config{
ListenAddr: "127.0.0.10:9205",
Timeout: 10 * time.Second,
ServerURL: "http://localhost",
StatsToken: "12w4352345zt§&/&)(&/",
TLSSkipVerify: false,
},
},
{
desc: "config and token from file",
args: []string{
"test",
"--config-file",
"testdata/statsTokenFile.yml",
},
env: map[string]string{},
wantErr: nil,
wantConfig: Config{
ListenAddr: "127.0.0.10:9205",
Timeout: 10 * time.Second,
ServerURL: "http://localhost",
StatsToken: "234563twtwewr",
TLSSkipVerify: false,
},
},
{
desc: "don't check tls certificates",
args: []string{
"test",
"--tls-skip-verify",
"true",
},
env: map[string]string{},
wantErr: nil,
wantConfig: Config{
ListenAddr: ":9205",
Timeout: 5 * time.Second,
ServerURL: "",
StatsToken: "",
TLSSkipVerify: true,
},
},
{
desc: "env config",
args: []string{
"test",
},
env: map[string]string{
envListenAddress: "127.0.0.11:9205",
envTimeout: "15s",
envServerURL: "http://localhost",
envStatsToken: "testpass/()/(g21!",
envTLSSkipVerify: "true",
},
wantErr: nil,
wantConfig: Config{
ListenAddr: "127.0.0.11:9205",
Timeout: 15 * time.Second,
ServerURL: "http://localhost",
StatsToken: "testpass/()/(g21!",
TLSSkipVerify: true,
},
},
{
desc: "minimal env",
args: []string{
"test",
},
env: map[string]string{
envServerURL: "http://localhost",
envStatsToken: "testpass/()/(g21!",
},
wantErr: nil,
wantConfig: Config{
ListenAddr: defaults.ListenAddr,
Timeout: defaults.Timeout,
ServerURL: "http://localhost",
StatsToken: "testpass/()/(g21!",
TLSSkipVerify: false,
},
},
{
desc: "show help",
args: []string{
"test",
"--help",
},
env: map[string]string{},
wantErr: nil,
wantConfig: Config{
RunMode: RunModeHelp,
},
},
{
desc: "show version",
args: []string{
"test",
"--version",
},
env: map[string]string{},
wantErr: nil,
wantConfig: Config{
RunMode: RunModeVersion,
},
},
{
desc: "wrongflag",
args: []string{
"test",
"--unknown",
},
env: map[string]string{},
wantErr: errors.New("error parsing flags: unknown flag: --unknown"),
},
{
desc: "env wrong duration",
args: []string{
"test",
},
env: map[string]string{
"FRIENDICA_TIMEOUT": "unknown",
},
wantErr: errors.New("error reading environment variables: time: invalid duration \"unknown\""),
},
{
desc: "token from file error",
args: []string{
"test",
"--server",
"http://localhost",
"--stats-token",
"@testdata/notfound",
},
env: map[string]string{},
wantErr: errors.New("can not read stats token file: open testdata/notfound: no such file or directory"),
},
{
desc: "config from file error",
args: []string{
"test",
"--config-file",
"testdata/notfound.yml",
},
env: map[string]string{},
wantErr: errors.New("error reading configuration file: open testdata/notfound.yml: no such file or directory"),
},
{
desc: "fail parsing tlsSkipVerify env",
args: []string{
"test",
},
env: map[string]string{
envTLSSkipVerify: "invalid",
},
wantErr: errors.New(`error reading environment variables: can not parse value for "FRIENDICA_TLS_SKIP_VERIFY": invalid`),
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.desc, func(t *testing.T) {
t.Parallel()
config, err := parseConfig(tc.args, testEnv(tc.env))
if !testutil.EqualErrorMessage(err, tc.wantErr) {
t.Errorf("got error %q, want %q", err, tc.wantErr)
}
if err != nil {
return
}
if diff := cmp.Diff(config, tc.wantConfig); diff != "" {
t.Errorf("config differs: -got +want\n%s", diff)
}
})
}
}
func TestConfigValidate(t *testing.T) {
tt := []struct {
desc string
config Config
wantErr error
}{
{
desc: "minimal",
config: Config{
ServerURL: "https://example.com",
StatsToken: "testtoken",
},
wantErr: nil,
},
{
desc: "no url",
config: Config{
StatsToken: "testtoken",
},
wantErr: errValidateNoServerURL,
},
{
desc: "auth help",
config: Config{
ServerURL: "https://example.com",
},
wantErr: errValidateNoStatsToken,
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.desc, func(t *testing.T) {
t.Parallel()
err := tc.config.Validate()
if !testutil.EqualErrorMessage(err, tc.wantErr) {
t.Errorf("got error %q, want %q", err, tc.wantErr)
}
})
}
}

4
internal/config/testdata/all.yml vendored Normal file
View file

@ -0,0 +1,4 @@
listenAddress: 127.0.0.10:9205
timeout: 10s
server: http://localhost
statsToken: 12w4352345zt§&/&)(&/

1
internal/config/testdata/statsToken vendored Normal file
View file

@ -0,0 +1 @@
234563twtwewr

View file

@ -0,0 +1,5 @@
listenAddress: 127.0.0.10:9205
timeout: 10s
server: http://localhost
username: testuser
statsToken: "@testdata/statsToken"

View file

@ -0,0 +1,363 @@
package metrics
import (
"fmt"
"friendica-exporter/internal/client"
"friendica-exporter/serverinfo"
"github.com/prometheus/client_golang/prometheus"
"github.com/sirupsen/logrus"
)
const (
metricPrefix = "friendica_"
labelErrorCauseOther = "other"
labelErrorCauseAuth = "auth"
labelErrorCauseRateLimit = "ratelimit"
)
var (
updateAvailableDesc = prometheus.NewDesc(
metricPrefix+"update_available",
"Contains information whether a system update is available (0 = no, 1 = yes). The available_version level contains the latest available Friendica version, whereas the version level contains the current installed Friendica version.",
[]string{"version", "available_version"}, nil)
updateStatus = prometheus.NewDesc(
metricPrefix+"update_status",
"Contains information whether a system update was successful (1 = no, 0 = yes).",
nil, nil)
updateDatabaseStatus = prometheus.NewDesc(
metricPrefix+"update_db_status",
"Contains information whether a database update was successful (0 = no, 1 = yes).",
nil, nil)
cronLastExecution = prometheus.NewDesc(
metricPrefix+"cron_last_execution",
"Contains information about the last execution of cron of this Instance.",
[]string{"datetime"}, nil)
workerLastExecution = prometheus.NewDesc(
metricPrefix+"worker_last_execution",
"Contains information about the last execution of worker of this Instance.",
[]string{"datetime"}, nil)
workerJPM = prometheus.NewDesc(
metricPrefix+"worker_jpm",
"Number of jobs per Minute.",
[]string{"frequency"}, nil)
workerTasksTotal = prometheus.NewDesc(
metricPrefix+"worker_tasks_total",
"Number of worker tasks of this Instance.",
[]string{"level"}, nil)
workerTasksActive = prometheus.NewDesc(
metricPrefix+"worker_tasks_active",
"Number of active worker tasks of this Instance.",
[]string{"level"}, nil)
usersTotal = prometheus.NewDesc(
metricPrefix+"users_total",
"Number of total users of this Instance.",
nil, nil)
usersActiveWeek = prometheus.NewDesc(
metricPrefix+"users_active_week",
"Number of active users of the last week.",
nil, nil)
usersActiveMonth = prometheus.NewDesc(
metricPrefix+"users_active_month",
"Number of active users of the last month.",
nil, nil)
usersActiveHalfYear = prometheus.NewDesc(
metricPrefix+"users_active_half_year",
"Number of active users of the last half year.",
nil, nil)
usersPending = prometheus.NewDesc(
metricPrefix+"users_pending",
"Number of pending users.",
nil, nil)
)
type friendicaCollector struct {
log logrus.FieldLogger
infoClient client.InfoClient
upMetric prometheus.Gauge
scrapeErrorsMetric *prometheus.CounterVec
}
func RegisterCollector(log logrus.FieldLogger, infoClient client.InfoClient) error {
c := &friendicaCollector{
log: log,
infoClient: infoClient,
upMetric: prometheus.NewGauge(prometheus.GaugeOpts{
Name: metricPrefix + "up",
Help: "Indicates if the metrics could be scraped by the exporter.",
}),
scrapeErrorsMetric: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: metricPrefix + "scrape_errors_total",
Help: "Counts the number of scrape errors by this collector.",
}, []string{"cause"}),
}
return prometheus.Register(c)
}
func (c *friendicaCollector) Describe(ch chan<- *prometheus.Desc) {
c.upMetric.Describe(ch)
c.scrapeErrorsMetric.Describe(ch)
ch <- updateAvailableDesc
ch <- updateStatus
ch <- updateDatabaseStatus
ch <- cronLastExecution
ch <- workerLastExecution
ch <- workerJPM
ch <- workerTasksTotal
ch <- workerTasksActive
ch <- usersTotal
ch <- usersActiveWeek
ch <- usersActiveMonth
ch <- usersActiveHalfYear
ch <- usersPending
}
func (c *friendicaCollector) Collect(ch chan<- prometheus.Metric) {
if err := c.collectFriendica(ch); err != nil {
c.log.Errorf("Error during scrape: %s", err)
cause := labelErrorCauseOther
if err == client.ErrNotAuthorized {
cause = labelErrorCauseAuth
} else if err == client.ErrRatelimit {
cause = labelErrorCauseRateLimit
}
c.scrapeErrorsMetric.WithLabelValues(cause).Inc()
c.upMetric.Set(0)
} else {
c.upMetric.Set(1)
}
c.upMetric.Collect(ch)
c.scrapeErrorsMetric.Collect(ch)
}
func (c *friendicaCollector) collectFriendica(ch chan<- prometheus.Metric) error {
status, err := c.infoClient()
if err != nil {
return err
}
return readMetrics(ch, status)
}
func readMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
if err := collectSimpleMetrics(ch, status); err != nil {
return err
}
if err := collectWorkerTotal(ch, status); err != nil {
return err
}
if err := collectJPM(ch, status); err != nil {
return err
}
if err := collectLastExecutions(ch, status); err != nil {
return err
}
if err := collectUpdate(ch, status); err != nil {
return err
}
return nil
}
func collectUpdate(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
updateInfo := status.Update
serverInfo := status.Server
updateAvailableValue := 0.0
// Fix small bug: its indicated as "true" even if there is no real update available.
if updateInfo.Available && serverInfo.Version != updateInfo.AvailableVersion {
updateAvailableValue = 1.0
}
metric, err := prometheus.NewConstMetric(updateAvailableDesc, prometheus.GaugeValue, updateAvailableValue, serverInfo.Version, updateInfo.AvailableVersion)
if err != nil {
return fmt.Errorf("error creating metric for %s: %w", updateAvailableDesc, err)
}
ch <- metric
return nil
}
type simpleMetric struct {
desc *prometheus.Desc
value float64
}
func collectSimpleMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
metrics := []simpleMetric{
{
desc: updateStatus,
value: float64(status.Update.Status),
},
{
desc: updateDatabaseStatus,
value: float64(status.Update.DatabaseStatus),
},
{
desc: usersTotal,
value: float64(status.Users.Total),
},
{
desc: usersActiveWeek,
value: float64(status.Users.ActiveWeek),
},
{
desc: usersActiveMonth,
value: float64(status.Users.ActiveMonth),
},
{
desc: usersActiveHalfYear,
value: float64(status.Users.ActiveHalfYear),
},
{
desc: usersPending,
value: float64(status.Users.Pending),
},
}
for _, m := range metrics {
metric, err := prometheus.NewConstMetric(m.desc, prometheus.GaugeValue, m.value)
if err != nil {
return fmt.Errorf("error creating metric for %s: %w", m.desc, err)
}
ch <- metric
}
return nil
}
type workerMetric struct {
desc *prometheus.Desc
value float64
level string
}
func collectWorkerTotal(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
metrics := []workerMetric{
{
desc: workerTasksActive,
value: float64(status.Worker.Active.Critical),
level: "critical",
},
{
desc: workerTasksActive,
value: float64(status.Worker.Active.High),
level: "high",
},
{
desc: workerTasksActive,
value: float64(status.Worker.Active.Medium),
level: "medium",
},
{
desc: workerTasksActive,
value: float64(status.Worker.Active.Low),
level: "low",
},
{
desc: workerTasksActive,
value: float64(status.Worker.Active.Negligible),
level: "negligible",
},
{
desc: workerTasksTotal,
value: float64(status.Worker.Total.Critical),
level: "critical",
},
{
desc: workerTasksTotal,
value: float64(status.Worker.Total.High),
level: "high",
},
{
desc: workerTasksTotal,
value: float64(status.Worker.Total.Medium),
level: "medium",
},
{
desc: workerTasksTotal,
value: float64(status.Worker.Total.Low),
level: "low",
},
{
desc: workerTasksTotal,
value: float64(status.Worker.Total.Negligible),
level: "negligible",
},
}
for _, m := range metrics {
metric, err := prometheus.NewConstMetric(m.desc, prometheus.GaugeValue, m.value, m.level)
if err != nil {
return fmt.Errorf("error creating metric for %s: %w", m.desc, err)
}
ch <- metric
}
return nil
}
type jpmMetric struct {
desc *prometheus.Desc
value float64
frequency string
}
func collectJPM(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
metrics := []jpmMetric{
{
desc: workerJPM,
value: float64(status.Worker.JPM.OneMinute),
frequency: "1 minute",
},
{
desc: workerJPM,
value: float64(status.Worker.JPM.ThreeMinutes),
frequency: "3 minutes",
},
{
desc: workerJPM,
value: float64(status.Worker.JPM.FiveMinutes),
frequency: "5 minutes",
},
}
for _, m := range metrics {
metric, err := prometheus.NewConstMetric(m.desc, prometheus.GaugeValue, m.value, m.frequency)
if err != nil {
return fmt.Errorf("error creating metric for %s: %w", m.desc, err)
}
ch <- metric
}
return nil
}
func collectLastExecutions(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error {
workerInfo := status.Worker
metric, err := prometheus.NewConstMetric(workerLastExecution, prometheus.GaugeValue, float64(workerInfo.LastExecution.Timestamp), workerInfo.LastExecution.DateTime)
if err != nil {
return fmt.Errorf("error creating metric for %s: %w", workerLastExecution, err)
}
ch <- metric
cronInfo := status.Cron
metric, err = prometheus.NewConstMetric(cronLastExecution, prometheus.GaugeValue, float64(cronInfo.LastExecution.Timestamp), cronInfo.LastExecution.DateTime)
if err != nil {
return fmt.Errorf("error creating metric for %s: %w", cronLastExecution, err)
}
ch <- metric
return nil
}

17
internal/metrics/info.go Normal file
View file

@ -0,0 +1,17 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
func RegisterStatsMetrics(version, gitCommit string) error {
statsMetric := prometheus.NewGauge(prometheus.GaugeOpts{
Name: metricPrefix + "exporter_info",
Help: "Information about the friendica-exporter.",
ConstLabels: prometheus.Labels{
"version": version,
"commit": gitCommit,
},
})
statsMetric.Set(1)
return prometheus.Register(statsMetric)
}

View file

@ -0,0 +1,20 @@
package testutil
import (
"strings"
)
// EqualErrorMessage compares two errors by just comparing their messages.
func EqualErrorMessage(a, b error) bool {
aMsg := "<nil>"
if a != nil {
aMsg = a.Error()
}
bMsg := "<nil>"
if b != nil {
bMsg = b.Error()
}
return strings.Compare(aMsg, bMsg) == 0
}

View file

@ -0,0 +1,70 @@
package testutil
import (
"errors"
"testing"
)
type testError struct{}
func (e testError) Error() string {
return "test message"
}
func TestEqualErrorMessage(t *testing.T) {
tt := []struct {
desc string
a error
b error
wantEqual bool
}{
{
desc: "two nil",
a: nil,
b: nil,
wantEqual: true,
},
{
desc: "a not nil",
a: errors.New("error A"),
b: nil,
wantEqual: false,
},
{
desc: "b not nil",
a: nil,
b: errors.New("error B"),
wantEqual: false,
},
{
desc: "both not nil",
a: errors.New("error A"),
b: errors.New("error B"),
wantEqual: false,
},
{
desc: "equal message, same type",
a: errors.New("test message"),
b: errors.New("test message"),
wantEqual: true,
},
{
desc: "equal message, different type",
a: errors.New("test message"),
b: testError{},
wantEqual: true,
},
}
for _, tc := range tt {
tc := tc
t.Run(tc.desc, func(t *testing.T) {
t.Parallel()
equal := EqualErrorMessage(tc.a, tc.b)
if equal != tc.wantEqual {
t.Errorf("got equal %v, want %v", equal, tc.wantEqual)
}
})
}
}