From a9235e96a1b026bdd331863ed90ba596a0b3b82b Mon Sep 17 00:00:00 2001 From: Markus Opolka <7090372+martialblog@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:11:31 +0100 Subject: [PATCH 1/5] Add application info --- icinga2_exporter.go | 1 + internal/collector/application.go | 59 +++++++++++++++++++++++++++++++ internal/icinga/client.go | 58 ++++++++++++++++++++++++++++-- internal/icinga/model.go | 23 ++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 internal/collector/application.go diff --git a/icinga2_exporter.go b/icinga2_exporter.go index f331f17..abbd030 100644 --- a/icinga2_exporter.go +++ b/icinga2_exporter.go @@ -111,6 +111,7 @@ func main() { // Register Collectors prometheus.MustRegister(collector.NewIcinga2CIBCollector(c, logger)) + prometheus.MustRegister(collector.NewIcinga2ApplicationCollector(c, logger)) if cliCollectorApiListener { prometheus.MustRegister(collector.NewIcinga2APICollector(c, logger)) diff --git a/internal/collector/application.go b/internal/collector/application.go new file mode 100644 index 0000000..5737ae2 --- /dev/null +++ b/internal/collector/application.go @@ -0,0 +1,59 @@ +package collector + +import ( + "log/slog" + + "github.com/martialblog/icinga2-exporter/internal/icinga" + + "github.com/prometheus/client_golang/prometheus" +) + +type Icinga2ApplicationCollector struct { + icingaClient *icinga.Client + logger *slog.Logger + info *prometheus.GaugeVec +} + +func NewIcinga2ApplicationCollector(client *icinga.Client, logger *slog.Logger) *Icinga2ApplicationCollector { + return &Icinga2ApplicationCollector{ + icingaClient: client, + logger: logger, + info: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "icinga2", + Name: "version_info", + Help: "A metric with a constant '1' value labeled by version", + }, + []string{"version"}, + ), + } +} + +func (collector *Icinga2ApplicationCollector) Describe(ch chan<- *prometheus.Desc) { + collector.info.Describe(ch) +} + +func (collector *Icinga2ApplicationCollector) Collect(ch chan<- prometheus.Metric) { + result, err := collector.icingaClient.GetApplicationMetrics() + + if err != nil { + collector.logger.Error("Could not retrieve Application metrics", "error", err.Error()) + return + } + + if len(result.Results) < 1 { + collector.logger.Debug("No results for Application metrics") + return + } + + // TODO: Use a custom unmarshal to avoid this + r := result.Results[0] + + collector.info.Reset() + + collector.info.With(prometheus.Labels{ + "version": r.Status.IcingaApplication.App.Version, + }).Set(1) + + collector.info.Collect(ch) +} diff --git a/internal/icinga/client.go b/internal/icinga/client.go index 22b15d8..2e916e3 100644 --- a/internal/icinga/client.go +++ b/internal/icinga/client.go @@ -11,8 +11,27 @@ import ( ) const ( - endpointCIB = "/status/CIB" - endpointApiListener = "/status/ApiListener" + endpointApiListener = "/status/ApiListener" + endpointApplication = "/status/IcingaApplication" + endpointCIB = "/status/CIB" + endpointCheckerComponent = "/status/CheckerComponent" + endpointCompatLogger = "/status/CompatLogger" + endpointElasticsearchWriter = "/status/ElasticsearchWriter" + endpointExternalCommandListener = "/status/ExternalCommandListener" + endpointFileLogger = "/status/FileLogger" + endpointGelfWriter = "/status/GelfWriter" + endpointGraphiteWriter = "/status/GraphiteWriter" + endpointIcingaApplication = "/status/IcingaApplication" + endpointIdoMysqlConnection = "/status/IdoMysqlConnection" + endpointIdoPgsqlConnection = "/status/IdoPgsqlConnection" + endpointInfluxdb2Writer = "/status/Influxdb2Writer" + endpointInfluxdbWriter = "/status/InfluxdbWriter" + endpointJournaldLogger = "/status/JournaldLogger" + endpointLivestatusListener = "/status/LivestatusListener" + endpointNotificationComponent = "/status/NotificationComponent" + endpointOpenTsdbWriter = "/status/OpenTsdbWriter" + endpointPerfdataWriter = "/status/PerfdataWriter" + endpointSyslogLogger = "/status/SyslogLogger" ) type Config struct { @@ -137,3 +156,38 @@ func (icinga *Client) GetCIBMetrics() (CIBResult, error) { return result, nil } + +func (icinga *Client) GetApplicationMetrics() (ApplicationResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + u := icinga.URL.JoinPath(endpointApplication) + + req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + + var result ApplicationResult + + if errReq != nil { + return result, fmt.Errorf("error creating request: %w", errReq) + } + + resp, errDo := icinga.Client.Do(req) + + if errDo != nil { + return result, fmt.Errorf("error performing request: %w", errDo) + } + + if resp.StatusCode != http.StatusOK { + return result, fmt.Errorf("request failed: %s", resp.Status) + } + + defer resp.Body.Close() + + errDecode := json.NewDecoder(resp.Body).Decode(&result) + + if errDecode != nil { + return result, fmt.Errorf("error parsing response: %w", errDecode) + } + + return result, nil +} diff --git a/internal/icinga/model.go b/internal/icinga/model.go index 98ed5fc..10ff42d 100644 --- a/internal/icinga/model.go +++ b/internal/icinga/model.go @@ -19,3 +19,26 @@ type CIBResult struct { Status map[string]float64 `json:"status,omitempty"` } `json:"results"` } + +type ApplicationResult struct { + Results []struct { + Name string `json:"name"` + Status struct { + IcingaApplication IcingaApplication `json:"icingaapplication"` + } `json:"status,omitempty"` + } `json:"results"` +} + +type IcingaApplication struct { + App App `json:"app"` +} + +type App struct { + EnableEventHandlers bool `json:"enable_event_handlers"` + EnableFlapping bool `json:"enable_flapping"` + EnableHostChecks bool `json:"enable_host_checks"` + EnableNotifications bool `json:"enable_notifications"` + EnablePerfdata bool `json:"enable_perfdata"` + EnableServiceChecks bool `json:"enable_service_checks"` + Version string `json:"version"` +} From 78564f76ca8909f942680709b0cbd2f0921ec7a6 Mon Sep 17 00:00:00 2001 From: Markus Opolka <7090372+martialblog@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:27:43 +0100 Subject: [PATCH 2/5] Dry refactor --- internal/icinga/client.go | 77 ++++++++++++++------------------------- 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/internal/icinga/client.go b/internal/icinga/client.go index 2e916e3..f1378c0 100644 --- a/internal/icinga/client.go +++ b/internal/icinga/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net" "net/http" "net/url" @@ -87,33 +88,43 @@ func NewClient(c Config) (*Client, error) { return cli, nil } -func (icinga *Client) GetApiListenerMetrics() (APIResult, error) { +func (icinga *Client) fetchJSON(endpoint string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - u := icinga.URL.JoinPath(endpointApiListener) + u := icinga.URL.JoinPath(endpoint) req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - var result APIResult - if errReq != nil { - return result, fmt.Errorf("error creating request: %w", errReq) + return []byte{}, fmt.Errorf("error creating request: %w", errReq) } resp, errDo := icinga.Client.Do(req) if errDo != nil { - return result, fmt.Errorf("error performing request: %w", errDo) + return []byte{}, fmt.Errorf("error performing request: %w", errDo) } if resp.StatusCode != http.StatusOK { - return result, fmt.Errorf("request failed: %s", resp.Status) + return []byte{}, fmt.Errorf("request failed: %s", resp.Status) } defer resp.Body.Close() - errDecode := json.NewDecoder(resp.Body).Decode(&result) + return io.ReadAll(resp.Body) +} + +func (icinga *Client) GetApiListenerMetrics() (APIResult, error) { + var result APIResult + + body, errBody := icinga.fetchJSON(endpointApiListener) + + if errBody != nil { + return result, fmt.Errorf("error fetching response: %w", errBody) + } + + errDecode := json.Unmarshal(body, &result) if errDecode != nil { return result, fmt.Errorf("error parsing response: %w", errDecode) @@ -123,32 +134,15 @@ func (icinga *Client) GetApiListenerMetrics() (APIResult, error) { } func (icinga *Client) GetCIBMetrics() (CIBResult, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - u := icinga.URL.JoinPath(endpointCIB) - - req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - var result CIBResult - if errReq != nil { - return result, fmt.Errorf("error creating request: %w", errReq) - } + body, errBody := icinga.fetchJSON(endpointCIB) - resp, errDo := icinga.Client.Do(req) - - if errDo != nil { - return result, fmt.Errorf("error performing request: %w", errDo) + if errBody != nil { + return result, fmt.Errorf("error fetching response: %w", errBody) } - if resp.StatusCode != http.StatusOK { - return result, fmt.Errorf("request failed: %s", resp.Status) - } - - defer resp.Body.Close() - - errDecode := json.NewDecoder(resp.Body).Decode(&result) + errDecode := json.Unmarshal(body, &result) if errDecode != nil { return result, fmt.Errorf("error parsing response: %w", errDecode) @@ -158,32 +152,15 @@ func (icinga *Client) GetCIBMetrics() (CIBResult, error) { } func (icinga *Client) GetApplicationMetrics() (ApplicationResult, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - u := icinga.URL.JoinPath(endpointApplication) - - req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - var result ApplicationResult - if errReq != nil { - return result, fmt.Errorf("error creating request: %w", errReq) - } - - resp, errDo := icinga.Client.Do(req) - - if errDo != nil { - return result, fmt.Errorf("error performing request: %w", errDo) - } + body, errBody := icinga.fetchJSON(endpointApplication) - if resp.StatusCode != http.StatusOK { - return result, fmt.Errorf("request failed: %s", resp.Status) + if errBody != nil { + return result, fmt.Errorf("error fetching response: %w", errBody) } - defer resp.Body.Close() - - errDecode := json.NewDecoder(resp.Body).Decode(&result) + errDecode := json.Unmarshal(body, &result) if errDecode != nil { return result, fmt.Errorf("error parsing response: %w", errDecode) From 92f43dd5a7c3a5f7123c07a3be74c1e7abc509b2 Mon Sep 17 00:00:00 2001 From: Markus Opolka <7090372+martialblog@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:45:31 +0100 Subject: [PATCH 3/5] Add simple internal cache --- .golangci.yml | 6 ++++ icinga2_exporter.go | 7 ++++ internal/icinga/cache.go | 47 +++++++++++++++++++++++++ internal/icinga/client.go | 74 +++++++++++++++++++++++++-------------- internal/icinga/model.go | 2 +- 5 files changed, 108 insertions(+), 28 deletions(-) create mode 100644 internal/icinga/cache.go diff --git a/.golangci.yml b/.golangci.yml index 7dfc5d3..3d9951f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,6 +2,11 @@ version: "2" run: tests: false linters: + settings: + revive: + rules: + - name: var-naming + disabled: true default: all disable: - wsl_v5 @@ -29,6 +34,7 @@ linters: - legacy - std-error-handling paths: + - internal/icinga/tls.go - third_party$ - builtin$ - examples$ diff --git a/icinga2_exporter.go b/icinga2_exporter.go index abbd030..b2b0a62 100644 --- a/icinga2_exporter.go +++ b/icinga2_exporter.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "os" + "time" "github.com/martialblog/icinga2-exporter/internal/collector" "github.com/martialblog/icinga2-exporter/internal/icinga" @@ -48,6 +49,7 @@ func main() { cliUsername string cliPassword string cliBaseURL string + cliCacheTTL uint cliVersion bool cliDebugLog bool cliInsecure bool @@ -56,6 +58,7 @@ func main() { flag.StringVar(&cliListenAddress, "web.listen-address", ":9665", "Address on which to expose metrics and web interface.") flag.StringVar(&cliMetricsPath, "web.metrics-path", "/metrics", "Path under which to expose metrics.") + flag.UintVar(&cliCacheTTL, "web.cache-ttl", 60, "Cache lifetime in seconds for the Icinga API responses") flag.StringVar(&cliBaseURL, "icinga.api", "https://localhost:5665/v1", "Path to the Icinga2 API") flag.StringVar(&cliUsername, "icinga.username", "", "Icinga2 API Username") @@ -93,6 +96,8 @@ func main() { Level: logLevel, })) + cacheTTL := time.Duration(cliCacheTTL) * time.Second + config := icinga.Config{ BasicAuthUsername: cliUsername, BasicAuthPassword: cliPassword, @@ -100,6 +105,7 @@ func main() { CertFile: cliCertFile, KeyFile: cliKeyFile, Insecure: cliInsecure, + CacheTTL: cacheTTL, IcingaAPIURI: *u, } @@ -118,6 +124,7 @@ func main() { } http.Handle(cliMetricsPath, promhttp.Handler()) + //nolint:errcheck http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte(` diff --git a/internal/icinga/cache.go b/internal/icinga/cache.go new file mode 100644 index 0000000..60d4fb5 --- /dev/null +++ b/internal/icinga/cache.go @@ -0,0 +1,47 @@ +package icinga + +import ( + "sync" + "time" +) + +type entry struct { + data []byte + fetchedAt time.Time +} + +type Cache struct { + m sync.Map + ttl time.Duration +} + +func NewCache(ttl time.Duration) *Cache { + return &Cache{ttl: ttl} +} + +func (c *Cache) Set(key string, b []byte) { + // We're not creating a copy here, data never is mutated + c.m.Store(key, &entry{ + data: b, + fetchedAt: time.Now(), + }) +} + +func (c *Cache) Get(key string) ([]byte, bool) { + if v, ok := c.m.Load(key); ok { + e, ok := v.(*entry) + + if !ok { + return nil, false + } + + if c.ttl > 0 && time.Since(e.fetchedAt) > c.ttl { + c.m.Delete(key) + return nil, false + } + // Again we're not creating a copy here, data never is mutated + return e.data, true + } + + return nil, false +} diff --git a/internal/icinga/client.go b/internal/icinga/client.go index f1378c0..f910add 100644 --- a/internal/icinga/client.go +++ b/internal/icinga/client.go @@ -42,12 +42,15 @@ type Config struct { CertFile string KeyFile string Insecure bool + CacheTTL time.Duration IcingaAPIURI url.URL } type Client struct { Client http.Client URL url.URL + cache *Cache + config Config } func NewClient(c Config) (*Client, error) { @@ -78,43 +81,20 @@ func NewClient(c Config) (*Client, error) { rt = newBasicAuthRoundTripper(c.BasicAuthUsername, c.BasicAuthPassword, rt) } + cache := NewCache(c.CacheTTL) + cli := &Client{ URL: c.IcingaAPIURI, Client: http.Client{ Transport: rt, }, + config: c, + cache: cache, } return cli, nil } -func (icinga *Client) fetchJSON(endpoint string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - u := icinga.URL.JoinPath(endpoint) - - req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - - if errReq != nil { - return []byte{}, fmt.Errorf("error creating request: %w", errReq) - } - - resp, errDo := icinga.Client.Do(req) - - if errDo != nil { - return []byte{}, fmt.Errorf("error performing request: %w", errDo) - } - - if resp.StatusCode != http.StatusOK { - return []byte{}, fmt.Errorf("request failed: %s", resp.Status) - } - - defer resp.Body.Close() - - return io.ReadAll(resp.Body) -} - func (icinga *Client) GetApiListenerMetrics() (APIResult, error) { var result APIResult @@ -168,3 +148,43 @@ func (icinga *Client) GetApplicationMetrics() (ApplicationResult, error) { return result, nil } + +func (icinga *Client) fetchJSON(endpoint string) ([]byte, error) { + // Lookup data in the cache we go out and bother the Icinga API + if elem, ok := icinga.cache.Get(endpoint); ok { + return elem, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + u := icinga.URL.JoinPath(endpoint) + + req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + + if errReq != nil { + return []byte{}, fmt.Errorf("error creating request: %w", errReq) + } + + resp, errDo := icinga.Client.Do(req) + + if errDo != nil { + return []byte{}, fmt.Errorf("error performing request: %w", errDo) + } + + if resp.StatusCode != http.StatusOK { + return []byte{}, fmt.Errorf("request failed: %s", resp.Status) + } + + defer resp.Body.Close() + + data, errRead := io.ReadAll(resp.Body) + + if errRead != nil { + return []byte{}, fmt.Errorf("reading response failed: %w", errRead) + } + + icinga.cache.Set(endpoint, data) + + return data, nil +} diff --git a/internal/icinga/model.go b/internal/icinga/model.go index 10ff42d..9ecc302 100644 --- a/internal/icinga/model.go +++ b/internal/icinga/model.go @@ -25,7 +25,7 @@ type ApplicationResult struct { Name string `json:"name"` Status struct { IcingaApplication IcingaApplication `json:"icingaapplication"` - } `json:"status,omitempty"` + } `json:"status"` } `json:"results"` } From 5cc6f90a3eddaf6eb7750e874e09fcc4a55c3546 Mon Sep 17 00:00:00 2001 From: Markus Opolka <7090372+martialblog@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:48:36 +0100 Subject: [PATCH 4/5] Add proper shutdown of HTTP server --- icinga2_exporter.go | 50 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/icinga2_exporter.go b/icinga2_exporter.go index b2b0a62..bbb9351 100644 --- a/icinga2_exporter.go +++ b/icinga2_exporter.go @@ -1,13 +1,16 @@ package main import ( + "context" + "errors" "flag" "fmt" - "log" "log/slog" "net/http" "net/url" "os" + "os/signal" + "syscall" "time" "github.com/martialblog/icinga2-exporter/internal/collector" @@ -76,7 +79,7 @@ func main() { flag.Parse() if cliVersion { - fmt.Printf("icinga-exporter version: %s\n", version) + fmt.Printf("icinga-exporter version: %s\n", buildVersion()) os.Exit(0) } @@ -96,6 +99,9 @@ func main() { Level: logLevel, })) + // In general, listen to gosec. But it this case, I don't think someone + // is going to overflow the uint TTL for the cache lifetime. + // nolint:gosec cacheTTL := time.Duration(cliCacheTTL) * time.Second config := icinga.Config{ @@ -123,6 +129,18 @@ func main() { prometheus.MustRegister(collector.NewIcinga2APICollector(c, logger)) } + // Create a central context to propagate a shutdown + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + srv := &http.Server{ + Addr: cliListenAddress, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + IdleTimeout: 5 * time.Second, + } + http.Handle(cliMetricsPath, promhttp.Handler()) //nolint:errcheck http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { @@ -136,7 +154,29 @@ func main() { `)) }) - log.Printf("Version: %s", buildVersion()) - log.Printf("Listening on address: %s", cliListenAddress) - log.Fatal(http.ListenAndServe(cliListenAddress, nil)) + go func() { + slog.Info("Listening on address", "port", cliListenAddress, "version", version, "commit", commit) + // nolint:noinlineerr + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + slog.Error("HTTP server error", "error", err.Error()) + } + + slog.Info("Received Shutdown. Stopped serving new connections.") + }() + + // The signal channel will block until we registered signals are received. + // We will then use a context with a timeout to shutdown the application gracefully. + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 5*time.Second) + defer shutdownCancel() + + // We are using Shutdown() with a timeout to gracefully + // shut down the server without interrupting any active connections. + // nolint:noinlineerr + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("HTTP shutdown error", "error", err.Error()) + } } From f6dd8200fdf2b2b86bf8ba24a6c13e10c623f31b Mon Sep 17 00:00:00 2001 From: Markus Opolka <7090372+martialblog@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:51:14 +0100 Subject: [PATCH 5/5] Enable golangci --- .github/workflows/golangci-lint.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/golangci-lint.yml diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..0cd3824 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,20 @@ +name: golangci-lint +on: + push: + tags: + - v* + branches: + - main + pull_request: + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.9.0