diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..27413d7
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,89 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+max_line_length = 120
+tab_width = 4
+ij_continuation_indent_size = 8
+ij_formatter_off_tag = @formatter:off
+ij_formatter_on_tag = @formatter:on
+ij_formatter_tags_enabled = true
+ij_smart_tabs = false
+ij_visual_guides =
+ij_wrap_on_typing = false
+
+[.editorconfig]
+ij_editorconfig_align_group_field_declarations = false
+ij_editorconfig_space_after_colon = false
+ij_editorconfig_space_after_comma = true
+ij_editorconfig_space_before_colon = false
+ij_editorconfig_space_before_comma = false
+ij_editorconfig_spaces_around_assignment_operators = true
+
+[{*.go,*.go2}]
+indent_style = tab
+ij_continuation_indent_size = 4
+ij_go_GROUP_CURRENT_PROJECT_IMPORTS = false
+ij_go_add_leading_space_to_comments = false
+ij_go_add_parentheses_for_single_import = false
+ij_go_call_parameters_new_line_after_left_paren = true
+ij_go_call_parameters_right_paren_on_new_line = true
+ij_go_call_parameters_wrap = off
+ij_go_fill_paragraph_width = 80
+ij_go_group_stdlib_imports = false
+ij_go_import_sorting = gofmt
+ij_go_keep_indents_on_empty_lines = false
+ij_go_local_group_mode = project
+ij_go_local_package_prefixes =
+ij_go_move_all_imports_in_one_declaration = false
+ij_go_move_all_stdlib_imports_in_one_group = false
+ij_go_remove_redundant_import_aliases = false
+ij_go_run_go_fmt_on_reformat = true
+ij_go_use_back_quotes_for_imports = false
+ij_go_wrap_comp_lit = off
+ij_go_wrap_comp_lit_newline_after_lbrace = true
+ij_go_wrap_comp_lit_newline_before_rbrace = true
+ij_go_wrap_func_params = off
+ij_go_wrap_func_params_newline_after_lparen = true
+ij_go_wrap_func_params_newline_before_rparen = true
+ij_go_wrap_func_result = off
+ij_go_wrap_func_result_newline_after_lparen = true
+ij_go_wrap_func_result_newline_before_rparen = true
+
+[{*.markdown,*.md}]
+ij_markdown_force_one_space_after_blockquote_symbol = true
+ij_markdown_force_one_space_after_header_symbol = true
+ij_markdown_force_one_space_after_list_bullet = true
+ij_markdown_force_one_space_between_words = true
+ij_markdown_format_tables = true
+ij_markdown_insert_quote_arrows_on_wrap = true
+ij_markdown_keep_indents_on_empty_lines = false
+ij_markdown_keep_line_breaks_inside_text_blocks = true
+ij_markdown_max_lines_around_block_elements = 1
+ij_markdown_max_lines_around_header = 1
+ij_markdown_max_lines_between_paragraphs = 1
+ij_markdown_min_lines_around_block_elements = 1
+ij_markdown_min_lines_around_header = 1
+ij_markdown_min_lines_between_paragraphs = 1
+ij_markdown_wrap_text_if_long = true
+ij_markdown_wrap_text_inside_blockquotes = true
+
+[{*.yaml,*.yml}]
+indent_size = 2
+ij_yaml_align_values_properties = do_not_align
+ij_yaml_autoinsert_sequence_marker = true
+ij_yaml_block_mapping_on_new_line = false
+ij_yaml_indent_sequence_value = true
+ij_yaml_keep_indents_on_empty_lines = false
+ij_yaml_keep_line_breaks = true
+ij_yaml_line_comment_add_space = false
+ij_yaml_line_comment_add_space_on_reformat = false
+ij_yaml_line_comment_at_first_column = true
+ij_yaml_sequence_on_new_line = false
+ij_yaml_space_before_colon = false
+ij_yaml_spaces_within_braces = true
+ij_yaml_spaces_within_brackets = true
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..26ac77b
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,45 @@
+name: Go Monit Exporter Deployment
+
+on:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ goos: [ linux, darwin, windows ]
+ goarch: [ amd64, arm64 ]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: 1.23.5
+
+ - name: Build binary
+ env:
+ GOOS: ${{ matrix.goos }}
+ GOARCH: ${{ matrix.goarch }}
+ run: |
+ mkdir -p build
+ if [ "${GOOS}" = "windows" ]; then
+ BINARY_NAME=monit-exporter-${GOOS}-${GOARCH}.exe
+ else
+ BINARY_NAME=monit-exporter-${GOOS}-${GOARCH}
+ fi
+ CGO_ENABLED=0 go build -ldflags="-s -w" -o build/${BINARY_NAME}
+
+ - name: Upload binaries to GitHub Releases
+ uses: softprops/action-gh-release@v1
+ with:
+ files: build/monit-exporter-*
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 62c8935..1a1abc7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,40 @@
-.idea/
\ No newline at end of file
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+.idea/**/aws.xml
+.idea/**/contentModel.xml
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+.idea/**/gradle.xml
+.idea/**/libraries
+cmake-build-*/
+.idea/**/mongoSettings.xml
+*.iws
+out/
+.idea_modules/
+atlassian-ide-plugin.xml
+.idea/replstate.xml
+.idea/sonarlint/
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+.idea/httpRequests
+.idea/caches/build_file_checksums.ser
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+*.test
+*.out
+go.work
+go.work.sum
+.env
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 9ac6267..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-language: go
-go:
- - 1.9
-script:
- - go test -v ./...
- - go build
- - sha256sum monit_exporter > monit_exporter.sha256
-deploy:
- provider: releases
- api_key:
- secure: s5ih9FzIXMpqLND+FINaDFcbQ0Tmp7BJQzZZ61lJs2Y+fWZAhgqPvxR1sTweOiZKKSR4oY5irskcvU5tEK6QzaKwuQH1ivtIMZjQr1LzGVoluYe3lVm5VqJTmmvZmiCNPvKeOqPUAVYvn0l9ifAnhbyk6H60yjevlfgd1RvHwggQhdPVCX6+/XKJI2BCmrSDTc5VjfHGd/sZ/9O7iiYAmbm9zAEJB1j/f7B11DvSHJ3bxSh6KWFWO9pcx0E7+k4krKFu2ICprztAvOV007xBuivX+c8my9gZ38hFBvf0oVL2uYYG7pxhUtvJdpR3PwLv4xIVim4CSPrxC3vUN7kspEFGlXNY4fsXbTdSo+QVvDrGG+VQ/3Ab/rJMHs1hF5F8eXS6jeIkt8ktXsxHlxJ0L72qaH7/gYcdHzlvP9TN6A5QEN+cSNMn1wH1SWWdegLn+JeI2fQ6ZXed5P8xTq+6mCPRhh1zyeea6cxBwUylzanF0Vo2GH26VX5A4VoT5OBw7pRc9jpVBhDLG2Z8OeXjeJUl6txB4dJYZzcDR1NIyh1bFUEfl6id9IZrmwKtnGBr2+iK9yuG74sb/+NLDbB37r9tXQ5qFVU2dQOWkUwrzEoz3m2wFTQmjVM02eh5IbwdIFSVPLkTYP+vyQSnIYf40hsVT3l542QI4iYLfAiU/XY=
- file:
- - monit_exporter
- - monit_exporter.sha256
- skip_cleanup: true
- on:
- public_repo: commercetools/monit_exporter
- tags: true
diff --git a/README.md b/README.md
index 7abea82..473d1ad 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,202 @@
# Monit Exporter for Prometheus
-Simple server that periodically scrapes monit status and exports checks information via HTTP for Prometheus.
+## English
+
+### Introduction
+
+Monit Exporter is a Prometheus Exporter
+that scrapes Monit status in XML format and exposes the metrics via an HTTP endpoint.
+
+### Features
+
+- **Enhanced Logging:**
+ - Detailed logs provide insights into HTTP requests, metric collection processes, and potential issues.
+
+- **Exposes Prometheus-Compatible Metrics:**
+ - Seamlessly integrates with Prometheus for monitoring Monit-managed services.
+
+- **Fully Configurable via Command-Line Flags:**
+ - Customize exporter behavior and Monit scraping parameters as needed.
+
+### Installation
+
+1. **Install [Go](https://golang.org/dl/) (version 1.23 or higher recommended).**
+
+2. **Clone the repository and build:**
+
+ ```bash
+ git clone https://github.com/commercetools/monit_exporter.git
+ cd monit_exporter
+ go build -o monit-exporter
+ ```
+
+### Usage
+
+#### Commands
+
+- **serve**: Starts the Monit Exporter server.
+
+#### Flags
+
+Below is an overview of the flags defined in `cmd/root.go`:
+
+| Flag | Default | Description |
+|--------------------|-------------------------------------------------------|-------------------------------------------------------------------------|
+| `listen-address` | `localhost:9388` | The address on which the exporter will listen (e.g., '0.0.0.0:9388'). |
+| `metrics-path` | `/metrics` | The HTTP path at which metrics are served (e.g., '/metrics'). |
+| `ignore-ssl` | `false` | Whether to skip SSL certificate verification for Monit endpoints. |
+| `monit-scrape-uri` | `http://localhost:2812/_status?format=xml&level=full` | The Monit status URL to scrape (XML format). |
+| `monit-user` | *(empty)* | Basic auth username for accessing Monit. |
+| `monit-password` | *(empty)* | Basic auth password for accessing Monit. |
+| `log-level` | `info` | Log level for the application (debug, info, warn, error, fatal, panic). |
+
+**Launch the exporter with desired flags:**
+
+```bash
+./monit-exporter serve \
+ --listen-address="0.0.0.0:9388" \
+ --monit-scrape-uri="http://localhost:2812/_status?format=xml&level=full" \
+ --monit-user="admin" \
+ --monit-password="monitpassword" \
+ --log-level="info"
+```
+
+**Visit the metrics endpoint:**
+
+```bash
+curl http://localhost:9388/metrics
+```
+
+### Running Tests
+
+To run the unit tests for the exporter and Monit components:
+
+```bash
+go test ./internal/exporter -v
+go test ./internal/monit -v
+```
+
+Ensure that all tests pass to verify the integrity of the exporter before deployment.
+
+### Project / Package Structure
+
+```
+.
+├── cmd
+│ ├── root.go (Defines root command and flags)
+│ └── serve.go (Implements 'serve' command, server startup)
+├── internal
+│ ├── config
+│ │ └── config.go (Holds the Config struct for the exporter)
+│ ├── exporter
+│ │ └── exporter.go (Implements the Prometheus Exporter logic)
+│ └── monit
+│ └── monit.go (Fetches and parses Monit status data)
+├── main.go (Entrypoint: calls cmd.Execute())
+├── README.md (This file)
+└── LICENSE (MIT License)
+```
+
+### License
+
+This project is licensed under the [MIT License](LICENSE).
+
+## 한국어
+
+### 소개
+
+Monit Exporter는 Monit 상태 정보를 XML 형식으로 수집하고 이를 Prometheus 메트릭으로 변환하여 HTTP 엔드포인트로 노출하는 익스포터입니다.
+
+### 기능
+
+- **향상된 로깅:**
+ - 자세한 로그를 통해 HTTP 요청, 메트릭 수집 과정 및 잠재적인 문제를 파악할 수 있습니다.
+
+- **Prometheus 호환 메트릭 제공:**
+ - Monit에서 관리하는 서비스를 Prometheus와 원활히 통합하여 모니터링할 수 있습니다.
+
+- **커맨드라인 플래그로 완벽히 구성 가능:**
+ - 익스포터의 동작과 Monit 스크래핑 매개변수를 필요에 따라 사용자 정의할 수 있습니다.
+
+### 설치
+
+1. **[Go](https://golang.org/dl/) (버전 1.23 이상 권장)을 설치합니다.**
+
+2. **레포지토리를 클론하고 빌드합니다:**
+
+ ```bash
+ git clone https://github.com/commercetools/monit_exporter.git
+ cd monit_exporter
+ go build -o monit-exporter
+ ```
+
+### 사용법
+
+#### 명령어
+
+- **serve**: Monit Exporter 서버를 시작합니다.
+
+#### 플래그
+
+`cmd/root.go`에 정의된 플래그는 다음과 같습니다:
+
+| 플래그 | 기본값 | 설명 |
+|--------------------|-------------------------------------------------------|---------------------------------------------------------|
+| `listen-address` | `localhost:9388` | 익스포터가 수신할 주소 및 포트 (예: '0.0.0.0:9388'). |
+| `metrics-path` | `/metrics` | 메트릭을 제공할 HTTP 경로 (예: '/metrics'). |
+| `ignore-ssl` | `false` | Monit 엔드포인트에 대해 SSL 인증서 검증을 무시할지 여부. |
+| `monit-scrape-uri` | `http://localhost:2812/_status?format=xml&level=full` | Monit 상태 정보를 수집할 XML URL. |
+| `monit-user` | *(없음)* | Monit에 접근하기 위한 Basic auth 사용자 이름. |
+| `monit-password` | *(없음)* | Monit에 접근하기 위한 Basic auth 비밀번호. |
+| `log-level` | `info` | 애플리케이션의 로그 레벨 (debug, info, warn, error, fatal, panic). |
+
+**익스포터를 실행하려면 다음 명령어를 사용합니다:**
-Build it:
```bash
-go build
+./monit-exporter serve \
+ --listen-address="0.0.0.0:9388" \
+ --monit-scrape-uri="http://localhost:2812/_status?format=xml&level=full" \
+ --monit-user="admin" \
+ --monit-password="monitpassword" \
+ --log-level="info"
```
-Run it:
+**메트릭 엔드포인트를 확인하려면 다음 명령어를 사용합니다:**
```bash
-./monit_exporter
+curl http://localhost:9388/metrics
```
-## Configuration
+### 테스트 실행
+
+익스포터 및 Monit 컴포넌트의 단위 테스트를 실행하려면:
-The application will look for configuration in "config.toml" file located in the same directory. Use -conf flag to override config file name and location.
+```bash
+go test ./internal/exporter -v
+go test ./internal/monit -v
+```
+
+모든 테스트를 통과시켜 익스포터의 무결성을 검증한 후 배포하십시오.
+
+### 프로젝트 / 패키지 구조
+
+```
+.
+├── cmd
+│ ├── root.go (루트 명령어와 플래그 정의)
+│ └── serve.go (서버 실행 명령어 구현)
+├── internal
+│ ├── config
+│ │ └── config.go (익스포터 설정 구조체 정의)
+│ ├── exporter
+│ │ └── exporter.go (Prometheus 익스포터 로직 구현)
+│ └── monit
+│ └── monit.go (Monit 상태 수집 및 파싱)
+├── main.go (진입점: cmd.Execute() 호출)
+├── README.md (이 파일)
+└── LICENSE (MIT 라이선스)
+```
-Configuration parameters:
+### 라이선스
-Parameter | Description | Default
---- | --- | ---
-`listen_address` | address and port to bind | localhost:9388
-`metrics_path` | relative path to expose metrics | /metrics
-`ignore_ssl` | whether of not to ignore ssl errors | false
-`monit_scrape_uri` | uri to get monit status | http://localhost:2812/_status?format=xml&level=full
-`monit_user` | user for monit basic auth, if needed | none
-`monit_password` | password for monit status, if needed | none
+이 프로젝트는 [MIT License](LICENSE)에 따라 라이선스가 부여됩니다.
diff --git a/cmd/root.go b/cmd/root.go
new file mode 100644
index 0000000..da339bf
--- /dev/null
+++ b/cmd/root.go
@@ -0,0 +1,86 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+var (
+ listenAddress string
+ metricsPath string
+ ignoreSSL bool
+ monitScrapeURI string
+ monitUser string
+ monitPassword string
+ logLevel string
+)
+
+// RootCmd is the base command for this application.
+var RootCmd = &cobra.Command{
+ Use: "monit-exporter",
+ Short: "Monit Exporter for Prometheus",
+ Long: "Prometheus Exporter that collects Monit status information and exposes metrics.",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ logrus.Debug("RootCmd is running without subcommand, displaying help message")
+ return cmd.Help()
+ },
+}
+
+// Execute runs the root command of the application.
+func Execute() {
+ logrus.Debug("Execute function called: attempting to run RootCmd.Execute()")
+ if err := RootCmd.Execute(); err != nil {
+ logrus.Errorf("Error occurred while executing RootCmd: %v", err)
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ logrus.Debug("Execute function finished: RootCmd.Execute() completed successfully")
+}
+
+func init() {
+ RootCmd.PersistentFlags().StringVar(
+ &listenAddress,
+ "listen-address",
+ "localhost:9388",
+ "The address on which the exporter will listen (e.g., '0.0.0.0:9388').",
+ )
+ RootCmd.PersistentFlags().StringVar(
+ &metricsPath,
+ "metrics-path",
+ "/metrics",
+ "The HTTP path at which metrics are served (e.g., '/metrics').",
+ )
+ RootCmd.PersistentFlags().BoolVar(
+ &ignoreSSL,
+ "ignore-ssl",
+ false,
+ "Whether to skip SSL certificate verification for Monit endpoints.",
+ )
+ RootCmd.PersistentFlags().StringVar(
+ &monitScrapeURI,
+ "monit-scrape-uri",
+ "http://localhost:2812/_status?format=xml&level=full",
+ "The Monit status URL to scrape (XML format).",
+ )
+ RootCmd.PersistentFlags().StringVar(
+ &monitUser,
+ "monit-user",
+ "",
+ "Basic auth username for accessing Monit.",
+ )
+ RootCmd.PersistentFlags().StringVar(
+ &monitPassword,
+ "monit-password",
+ "",
+ "Basic auth password for accessing Monit.",
+ )
+ RootCmd.PersistentFlags().StringVar(
+ &logLevel,
+ "log-level",
+ "info",
+ "Log level for the application (debug, info, warn, error, fatal, panic).",
+ )
+}
diff --git a/cmd/root_test.go b/cmd/root_test.go
new file mode 100644
index 0000000..b82e764
--- /dev/null
+++ b/cmd/root_test.go
@@ -0,0 +1,39 @@
+package cmd
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/spf13/cobra"
+)
+
+// TestRootCmd_Help verifies that running RootCmd without subcommands prints help.
+func TestRootCmd_Help(t *testing.T) {
+ buf := new(bytes.Buffer)
+ RootCmd.SetOut(buf)
+ RootCmd.SetArgs([]string{})
+
+ err := RootCmd.Execute()
+ if err != nil {
+ t.Fatalf("RootCmd execution failed: %v", err)
+ }
+
+ output := buf.String()
+ if len(output) == 0 {
+ t.Errorf("Expected help output, got empty string")
+ }
+}
+
+// TestRootCmd_Execute checks if Execute() runs RootCmd properly.
+func TestRootCmd_Execute(t *testing.T) {
+ testCmd := &cobra.Command{
+ Use: "test",
+ Short: "Test subcommand",
+ Run: func(cmd *cobra.Command, args []string) {},
+ }
+ RootCmd.AddCommand(testCmd)
+ defer RootCmd.RemoveCommand(testCmd)
+
+ RootCmd.SetArgs([]string{"test"})
+ Execute()
+}
diff --git a/cmd/serve.go b/cmd/serve.go
new file mode 100644
index 0000000..ab0af5e
--- /dev/null
+++ b/cmd/serve.go
@@ -0,0 +1,157 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ _ "embed"
+
+ "github.com/commercetools/monit-exporter/internal/config"
+ "github.com/commercetools/monit-exporter/internal/exporter"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+)
+
+//go:embed static/favicon.ico
+var embeddedFavicon []byte
+
+var serveCmd = &cobra.Command{
+ Use: "serve",
+ Short: "Run the Monit Exporter server",
+ Long: "Run the Monit Exporter server that collects Monit status and exposes Prometheus metrics.",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ logrus.Debug("serveCmd invoked: starting Monit Exporter server")
+
+ if err := config.SetLogLevel(logLevel); err != nil {
+ logrus.Errorf("Failed to set log level: %v", err)
+ return fmt.Errorf("failed to set log level: %w", err)
+ }
+ logrus.Debugf("Log level set to '%s'", logLevel)
+
+ cfg := &config.Config{
+ ListenAddress: listenAddress,
+ MetricsPath: metricsPath,
+ IgnoreSSL: ignoreSSL,
+ MonitScrapeURI: monitScrapeURI,
+ MonitUser: monitUser,
+ MonitPassword: monitPassword,
+ LogLevel: logLevel,
+ }
+ logrus.Debugf("Server configuration loaded: %+v", cfg)
+
+ exp, err := exporter.NewExporter(cfg)
+ if err != nil {
+ logrus.Errorf("Failed to create exporter: %v", err)
+ return fmt.Errorf("failed to create exporter: %w", err)
+ }
+ logrus.Debug("Registering exporter to Prometheus")
+ prometheus.MustRegister(exp)
+
+ mux := http.NewServeMux()
+ mux.Handle(cfg.MetricsPath, promhttp.Handler())
+ mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
+ if 0 < len(embeddedFavicon) {
+ w.Header().Set("Content-Type", "image/x-icon")
+ _, _ = w.Write(embeddedFavicon)
+ } else {
+ http.ServeFile(w, r, filepath.Join("static", "favicon.ico"))
+ }
+ })
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ logrus.Debugf("Root path request received from %s", r.RemoteAddr)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = fmt.Fprintf(
+ w,
+ `
Monit ExporterMonit Exporter
Metrics
`,
+ cfg.MetricsPath,
+ )
+ })
+
+ server := &http.Server{Addr: cfg.ListenAddress, Handler: commonLogHandler(mux)}
+ shutdownCh := make(chan os.Signal, 1)
+ signal.Notify(shutdownCh, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ sig := <-shutdownCh
+ logrus.Infof("Received shutdown signal: %v. Attempting to stop Monit Exporter gracefully...", sig)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := server.Shutdown(ctx); err != nil {
+ logrus.Errorf("Graceful shutdown failed: %v", err)
+ } else {
+ logrus.Info("Server shut down gracefully")
+ }
+ }()
+
+ logrus.Infof("Starting Monit Exporter on %s", cfg.ListenAddress)
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ logrus.Errorf("Failed to start server: %v", err)
+ return fmt.Errorf("failed to start server: %w", err)
+ }
+ logrus.Info("Monit Exporter stopped")
+ return nil
+ },
+}
+
+func init() {
+ RootCmd.AddCommand(serveCmd)
+}
+
+// LoggingResponseWriter wraps a http.ResponseWriter to track status code and size.
+type LoggingResponseWriter struct {
+ http.ResponseWriter
+ statusCode int
+ size int
+}
+
+// NewLoggingResponseWriter creates a new LoggingResponseWriter with default status code 200.
+func NewLoggingResponseWriter(w http.ResponseWriter) *LoggingResponseWriter {
+ return &LoggingResponseWriter{
+ ResponseWriter: w,
+ statusCode: http.StatusOK,
+ }
+}
+
+// WriteHeader sets the status code and calls the underlying ResponseWriter's WriteHeader.
+func (lrw *LoggingResponseWriter) WriteHeader(code int) {
+ lrw.statusCode = code
+ lrw.ResponseWriter.WriteHeader(code)
+}
+
+// Write writes the data and keeps track of the size in bytes.
+func (lrw *LoggingResponseWriter) Write(b []byte) (int, error) {
+ size, err := lrw.ResponseWriter.Write(b)
+ lrw.size += size
+ return size, err
+}
+
+// commonLogHandler returns an http.Handler that logs requests in Common Log Format.
+func commonLogHandler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ lrw := NewLoggingResponseWriter(w)
+ next.ServeHTTP(lrw, r)
+ duration := time.Since(start)
+ logrus.Infof("[commonLogHandler] %s - - [%s] \"%s %s %s\" %d %d \"%s\" \"%s\" %.4f",
+ r.RemoteAddr,
+ start.Format("02/Jan/2006:15:04:05 -0700"),
+ r.Method,
+ r.RequestURI,
+ r.Proto,
+ lrw.statusCode,
+ lrw.size,
+ r.Referer(),
+ r.UserAgent(),
+ duration.Seconds(),
+ )
+ logrus.Debugf("[commonLogHandler] Request processed: Method=%s, URI=%s, Duration=%.4fs", r.Method, r.RequestURI, duration.Seconds())
+ })
+}
diff --git a/cmd/serve_test.go b/cmd/serve_test.go
new file mode 100644
index 0000000..e28f7f2
--- /dev/null
+++ b/cmd/serve_test.go
@@ -0,0 +1,62 @@
+package cmd
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestServeCmd_Help(t *testing.T) {
+ buf := new(bytes.Buffer)
+ RootCmd.SetOut(buf)
+ RootCmd.SetArgs([]string{"serve", "--help"})
+
+ err := serveCmd.Execute()
+ if err != nil {
+ t.Fatalf("ServeCmd execution failed with --help: %v", err)
+ }
+
+ output := buf.String()
+ if len(output) == 0 {
+ t.Errorf("Expected help output for serve command, got empty string")
+ }
+}
+
+func TestCommonLogHandler(t *testing.T) {
+ dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("OK"))
+ })
+ handler := commonLogHandler(dummyHandler)
+
+ req := httptest.NewRequest("GET", "/dummy", nil)
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+ if w.Result().StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Result().StatusCode)
+ }
+}
+
+func TestNewLoggingResponseWriter(t *testing.T) {
+ rec := httptest.NewRecorder()
+ lrw := NewLoggingResponseWriter(rec)
+ lrw.WriteHeader(http.StatusAccepted)
+
+ if lrw.statusCode != http.StatusAccepted {
+ t.Errorf("Expected status code 202, got %d", lrw.statusCode)
+ }
+
+ testData := []byte("Hello, World!")
+ n, err := lrw.Write(testData)
+ if err != nil {
+ t.Fatalf("Write failed: %v", err)
+ }
+ if n != len(testData) {
+ t.Errorf("Expected %d bytes, wrote %d", len(testData), n)
+ }
+ if lrw.size != len(testData) {
+ t.Errorf("Expected lrw.size=%d, got %d", len(testData), lrw.size)
+ }
+}
diff --git a/cmd/static/favicon.ico b/cmd/static/favicon.ico
new file mode 100644
index 0000000..34bd1fb
Binary files /dev/null and b/cmd/static/favicon.ico differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..20ab80f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,26 @@
+module github.com/commercetools/monit-exporter
+
+go 1.23.5
+
+require (
+ github.com/prometheus/client_golang v1.20.5
+ github.com/sirupsen/logrus v1.9.3
+ github.com/spf13/cobra v1.8.1
+ golang.org/x/net v0.34.0
+)
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/klauspost/compress v1.17.11 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.62.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ golang.org/x/sys v0.29.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ google.golang.org/protobuf v1.36.3 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..71eb303
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,52 @@
+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/cpuguy83/go-md2man/v2 v2.0.4/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 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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
+github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+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/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.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
+github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
+github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
+google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..836e764
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,29 @@
+package config
+
+import (
+ "github.com/sirupsen/logrus"
+)
+
+// Config holds the configuration values needed by the Monit Exporter.
+type Config struct {
+ ListenAddress string
+ MetricsPath string
+ IgnoreSSL bool
+ MonitScrapeURI string
+ MonitUser string
+ MonitPassword string
+ LogLevel string
+}
+
+// SetLogLevel sets the global log level of logrus based on the given string.
+func SetLogLevel(levelStr string) error {
+ logrus.Debugf("SetLogLevel called with levelStr=%s", levelStr)
+ level, err := logrus.ParseLevel(levelStr)
+ if err != nil {
+ logrus.Errorf("Failed to parse log level: %v", err)
+ return err
+ }
+ logrus.SetLevel(level)
+ logrus.Infof("Log level successfully set to '%s'", level.String())
+ return nil
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..183d27d
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,24 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/sirupsen/logrus"
+)
+
+func TestSetLogLevel_Success(t *testing.T) {
+ err := SetLogLevel("debug")
+ if err != nil {
+ t.Fatalf("Expected no error for valid level 'debug', got %v", err)
+ }
+ if logrus.GetLevel() != logrus.DebugLevel {
+ t.Errorf("Expected log level=DebugLevel, got %s", logrus.GetLevel())
+ }
+}
+
+func TestSetLogLevel_Invalid(t *testing.T) {
+ err := SetLogLevel("notalevel")
+ if err == nil {
+ t.Fatal("Expected an error for invalid log level 'notalevel', got nil")
+ }
+}
diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go
new file mode 100644
index 0000000..9bbbff5
--- /dev/null
+++ b/internal/exporter/exporter.go
@@ -0,0 +1,416 @@
+package exporter
+
+import (
+ "errors"
+ "slices"
+ "strconv"
+ "sync"
+
+ "github.com/commercetools/monit-exporter/internal/config"
+ "github.com/commercetools/monit-exporter/internal/monit"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/sirupsen/logrus"
+)
+
+const (
+ namespace = "monit"
+)
+
+var (
+ // ErrNilConfig is returned when a nil config is provided to NewExporter.
+ ErrNilConfig = errors.New("config is nil")
+)
+
+// serviceTypes maps Monit service type integers to descriptive strings.
+var serviceTypes = map[int]string{
+ 0: "Filesystem",
+ 1: "Directory",
+ 2: "File",
+ 3: "Process",
+ 4: "Remote host",
+ 5: "System",
+ 6: "Fifo",
+ 7: "Program",
+ 8: "Network",
+}
+
+// Exporter collects Monit metrics and exposes them to Prometheus.
+type Exporter struct {
+ cfg *config.Config
+ mutex sync.Mutex
+
+ up prometheus.Gauge
+ status *prometheus.GaugeVec
+
+ blockUsage *prometheus.GaugeVec
+ blockTotal *prometheus.GaugeVec
+ blockPercent *prometheus.GaugeVec
+
+ inodeUsage *prometheus.GaugeVec
+ inodeTotal *prometheus.GaugeVec
+ inodePercent *prometheus.GaugeVec
+
+ portResponseTime *prometheus.GaugeVec
+
+ systemLoadAvg01 *prometheus.GaugeVec
+ systemLoadAvg05 *prometheus.GaugeVec
+ systemLoadAvg15 *prometheus.GaugeVec
+
+ systemCPUUser *prometheus.GaugeVec
+ systemCPUSystem *prometheus.GaugeVec
+ systemCPUWait *prometheus.GaugeVec
+
+ systemMemPercent *prometheus.GaugeVec
+ systemMemKilobytes *prometheus.GaugeVec
+ systemSwapPercent *prometheus.GaugeVec
+ systemSwapKilobytes *prometheus.GaugeVec
+}
+
+// NewExporter creates a new Exporter using the given Config.
+func NewExporter(cfg *config.Config) (*Exporter, error) {
+ if cfg == nil {
+ logrus.Error("NewExporter: config is nil")
+ return nil, ErrNilConfig
+ }
+
+ logrus.Debugf("NewExporter: creating exporter with ListenAddress=%s, MonitScrapeURI=%s",
+ cfg.ListenAddress, cfg.MonitScrapeURI)
+
+ labelNames := []string{"service_name", "service_type", "service_monitor_status"}
+
+ return &Exporter{
+ cfg: cfg,
+
+ up: prometheus.NewGauge(prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "exporter_up",
+ Help: "Indicates whether the Monit endpoint is reachable (1) or not (0).",
+ }),
+ status: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "exporter_service_check",
+ Help: "Indicates the status field from Monit.",
+ },
+ labelNames,
+ ),
+
+ blockUsage: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_block_usage_bytes",
+ Help: "Block usage for filesystem-based services.",
+ },
+ labelNames,
+ ),
+ blockTotal: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_block_total_bytes",
+ Help: "Block total capacity for filesystem-based services.",
+ },
+ labelNames,
+ ),
+ blockPercent: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_block_usage_percent",
+ Help: "Block usage percentage for filesystem-based services.",
+ },
+ labelNames,
+ ),
+
+ inodeUsage: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_inode_usage",
+ Help: "Inode usage for filesystem-based services.",
+ },
+ labelNames,
+ ),
+ inodeTotal: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_inode_total",
+ Help: "Total number of inodes for filesystem-based services.",
+ },
+ labelNames,
+ ),
+ inodePercent: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_inode_usage_percent",
+ Help: "Inode usage percentage for filesystem-based services.",
+ },
+ labelNames,
+ ),
+
+ portResponseTime: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_port_response_seconds",
+ Help: "Response time in seconds for port-based checks.",
+ },
+ labelNames,
+ ),
+
+ systemLoadAvg01: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_loadavg_01",
+ Help: "1-minute load average for system-based services.",
+ },
+ labelNames,
+ ),
+ systemLoadAvg05: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_loadavg_05",
+ Help: "5-minute load average for system-based services.",
+ },
+ labelNames,
+ ),
+ systemLoadAvg15: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_loadavg_15",
+ Help: "15-minute load average for system-based services.",
+ },
+ labelNames,
+ ),
+
+ systemCPUUser: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_cpu_user_percent",
+ Help: "CPU usage in user space (percent).",
+ },
+ labelNames,
+ ),
+ systemCPUSystem: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_cpu_system_percent",
+ Help: "CPU usage in kernel space (percent).",
+ },
+ labelNames,
+ ),
+ systemCPUWait: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_cpu_wait_percent",
+ Help: "CPU usage waiting for I/O (percent).",
+ },
+ labelNames,
+ ),
+
+ systemMemPercent: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_memory_usage_percent",
+ Help: "Memory usage percentage for system-based services.",
+ },
+ labelNames,
+ ),
+ systemMemKilobytes: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_memory_usage_kilobytes",
+ Help: "Memory usage in kilobytes for system-based services.",
+ },
+ labelNames,
+ ),
+ systemSwapPercent: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_swap_usage_percent",
+ Help: "Swap usage percentage for system-based services.",
+ },
+ labelNames,
+ ),
+ systemSwapKilobytes: prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Namespace: namespace,
+ Name: "service_system_swap_usage_kilobytes",
+ Help: "Swap usage in kilobytes for system-based services.",
+ },
+ labelNames,
+ ),
+ }, nil
+}
+
+// Describe sends the descriptors of each metric to the provided channel.
+func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
+ e.up.Describe(ch)
+ e.status.Describe(ch)
+
+ e.blockUsage.Describe(ch)
+ e.blockTotal.Describe(ch)
+ e.blockPercent.Describe(ch)
+
+ e.inodeUsage.Describe(ch)
+ e.inodeTotal.Describe(ch)
+ e.inodePercent.Describe(ch)
+
+ e.portResponseTime.Describe(ch)
+
+ e.systemLoadAvg01.Describe(ch)
+ e.systemLoadAvg05.Describe(ch)
+ e.systemLoadAvg15.Describe(ch)
+
+ e.systemCPUUser.Describe(ch)
+ e.systemCPUSystem.Describe(ch)
+ e.systemCPUWait.Describe(ch)
+
+ e.systemMemPercent.Describe(ch)
+ e.systemMemKilobytes.Describe(ch)
+ e.systemSwapPercent.Describe(ch)
+ e.systemSwapKilobytes.Describe(ch)
+
+ logrus.Debug("Exporter.Describe: described all metrics to the channel")
+}
+
+// Collect is called by the Prometheus registry to gather metrics.
+func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
+ e.mutex.Lock()
+ defer e.mutex.Unlock()
+
+ logrus.Debug("Exporter.Collect: resetting metrics before scrape")
+
+ e.status.Reset()
+ e.blockUsage.Reset()
+ e.blockTotal.Reset()
+ e.blockPercent.Reset()
+ e.inodeUsage.Reset()
+ e.inodeTotal.Reset()
+ e.inodePercent.Reset()
+ e.portResponseTime.Reset()
+ e.systemLoadAvg01.Reset()
+ e.systemLoadAvg05.Reset()
+ e.systemLoadAvg15.Reset()
+ e.systemCPUUser.Reset()
+ e.systemCPUSystem.Reset()
+ e.systemCPUWait.Reset()
+ e.systemMemPercent.Reset()
+ e.systemMemKilobytes.Reset()
+ e.systemSwapPercent.Reset()
+ e.systemSwapKilobytes.Reset()
+
+ err := e.scrape()
+ if err != nil {
+ logrus.Errorf("Exporter.Collect: scrape error: %v", err)
+ }
+
+ e.up.Collect(ch)
+ e.status.Collect(ch)
+ e.blockUsage.Collect(ch)
+ e.blockTotal.Collect(ch)
+ e.blockPercent.Collect(ch)
+ e.inodeUsage.Collect(ch)
+ e.inodeTotal.Collect(ch)
+ e.inodePercent.Collect(ch)
+ e.portResponseTime.Collect(ch)
+ e.systemLoadAvg01.Collect(ch)
+ e.systemLoadAvg05.Collect(ch)
+ e.systemLoadAvg15.Collect(ch)
+ e.systemCPUUser.Collect(ch)
+ e.systemCPUSystem.Collect(ch)
+ e.systemCPUWait.Collect(ch)
+ e.systemMemPercent.Collect(ch)
+ e.systemMemKilobytes.Collect(ch)
+ e.systemSwapPercent.Collect(ch)
+ e.systemSwapKilobytes.Collect(ch)
+
+ logrus.Debug("Exporter.Collect: metrics collected and sent to the channel")
+}
+
+// scrape fetches Monit status and updates the metrics.
+func (e *Exporter) scrape() error {
+ logrus.Debug("Exporter.scrape: fetching Monit status")
+ data, err := monit.FetchMonitStatus(e.cfg)
+ if err != nil {
+ logrus.Warnf("Exporter.scrape: failed to fetch Monit status: %v", err)
+ e.up.Set(0)
+ e.status.Reset()
+ return err
+ }
+ logrus.Debugf("Exporter.scrape: successfully fetched Monit status (%d bytes)", len(data))
+
+ parsed, err := monit.ParseMonitStatus(data)
+ if err != nil {
+ logrus.Warnf("Exporter.scrape: failed to parse Monit status: %v", err)
+ e.up.Set(0)
+ e.status.Reset()
+ return err
+ }
+ logrus.Debug("Exporter.scrape: successfully parsed Monit status")
+
+ e.up.Set(1)
+ logrus.Debug("Exporter.scrape: set exporter_up to 1 (Monit is reachable)")
+
+ for service := range slices.Values(parsed.Services) {
+ serviceType, ok := serviceTypes[service.Type]
+ if !ok {
+ serviceType = "unknown"
+ logrus.Warnf("Exporter.scrape: unknown service service_type=%d, serviceNameservice_name=%s", service.Type, service.Name)
+ }
+ serviceMonitorStatus := strconv.Itoa(service.Monitor)
+
+ e.status.With(prometheus.Labels{
+ "service_name": service.Name,
+ "service_type": serviceType,
+ "service_monitor_status": serviceMonitorStatus,
+ }).Set(float64(service.Status))
+
+ logrus.Debugf(
+ "Exporter.scrape: service_name=%s, service_type=%s, service_monitor_status=%d, service_status=%d",
+ service.Name,
+ serviceType,
+ service.Monitor,
+ service.Status,
+ )
+
+ e.collectServiceMetrics(service, serviceType, serviceMonitorStatus)
+ }
+ return nil
+}
+
+// collectServiceMetrics updates detailed metrics for a single Monit service.
+func (e *Exporter) collectServiceMetrics(service monit.Service, serviceType, serviceMonitorStatus string) {
+ labels := prometheus.Labels{
+ "service_name": service.Name,
+ "service_type": serviceType,
+ "service_monitor_status": serviceMonitorStatus,
+ }
+
+ if service.Block != nil {
+ e.blockUsage.With(labels).Set(service.Block.Usage)
+ e.blockTotal.With(labels).Set(service.Block.Total)
+ e.blockPercent.With(labels).Set(service.Block.Percent)
+ }
+
+ if service.Inode != nil {
+ e.inodeUsage.With(labels).Set(float64(service.Inode.Usage))
+ e.inodeTotal.With(labels).Set(float64(service.Inode.Total))
+ e.inodePercent.With(labels).Set(service.Inode.Percent)
+ }
+
+ if service.Port != nil {
+ e.portResponseTime.With(labels).Set(service.Port.Responsetime)
+ }
+
+ if service.System != nil {
+ e.systemLoadAvg01.With(labels).Set(service.System.Load.Avg01)
+ e.systemLoadAvg05.With(labels).Set(service.System.Load.Avg05)
+ e.systemLoadAvg15.With(labels).Set(service.System.Load.Avg15)
+
+ e.systemCPUUser.With(labels).Set(service.System.CPU.User)
+ e.systemCPUSystem.With(labels).Set(service.System.CPU.System)
+ e.systemCPUWait.With(labels).Set(service.System.CPU.Wait)
+
+ e.systemMemPercent.With(labels).Set(service.System.Memory.Percent)
+ e.systemMemKilobytes.With(labels).Set(float64(service.System.Memory.Kilobyte))
+ e.systemSwapPercent.With(labels).Set(service.System.Swap.Percent)
+ e.systemSwapKilobytes.With(labels).Set(float64(service.System.Swap.Kilobyte))
+ }
+}
diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go
new file mode 100644
index 0000000..4144581
--- /dev/null
+++ b/internal/exporter/exporter_test.go
@@ -0,0 +1,116 @@
+package exporter
+
+import (
+ "fmt"
+ "github.com/prometheus/client_golang/prometheus"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/commercetools/monit-exporter/internal/config"
+ "github.com/prometheus/client_golang/prometheus/testutil"
+ "github.com/sirupsen/logrus"
+)
+
+// TestNewExporter_NilConfig verifies that providing a nil config returns an error.
+func TestNewExporter_NilConfig(t *testing.T) {
+ t.Log("Testing NewExporter with nil config")
+ exp, err := NewExporter(nil)
+ if err == nil {
+ t.Errorf("Expected ErrNilConfig, got no error")
+ }
+ if exp != nil {
+ t.Errorf("Expected nil Exporter, got a non-nil instance")
+ }
+}
+
+// TestNewExporter_ValidConfig verifies that a valid config creates an Exporter successfully.
+func TestNewExporter_ValidConfig(t *testing.T) {
+ t.Log("Testing NewExporter with a valid config")
+ cfg := &config.Config{ListenAddress: "0.0.0.0:9999"}
+ exp, err := NewExporter(cfg)
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+ if exp == nil {
+ t.Fatal("Expected a non-nil Exporter, got nil")
+ }
+}
+
+// TestExporter_Collect_Success uses a mock server returning a valid Monit XML response.
+func TestExporter_Collect_Success(t *testing.T) {
+ t.Log("Testing Exporter.Collect with a successful Monit response")
+
+ mockXML := `
+
+ 5.26.0
+
+
+ rootfs
+ 0
+ 1
+
+ `
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Logf("Mock server received request: %s %s", r.Method, r.URL.Path)
+ w.WriteHeader(http.StatusOK)
+ _, _ = fmt.Fprintln(w, mockXML)
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{
+ MonitScrapeURI: server.URL,
+ }
+ exp, err := NewExporter(cfg)
+ if err != nil {
+ t.Fatalf("Failed to create Exporter: %v", err)
+ }
+
+ ch := make(chan prometheus.Metric)
+ go func() {
+ exp.Collect(ch)
+ close(ch)
+ }()
+
+ for range ch {
+ }
+
+ upValue := testutil.ToFloat64(exp.up)
+ if upValue != 1 {
+ t.Errorf("Expected exporter_up=1, got %f", upValue)
+ }
+}
+
+// TestExporter_Collect_MonitError uses a mock server that returns an HTTP error.
+func TestExporter_Collect_MonitError(t *testing.T) {
+ t.Log("Testing Exporter.Collect when Monit returns an error status code")
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "some error", http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{MonitScrapeURI: server.URL}
+ exp, err := NewExporter(cfg)
+ if err != nil {
+ t.Fatalf("Failed to create Exporter: %v", err)
+ }
+
+ ch := make(chan prometheus.Metric)
+ go func() {
+ exp.Collect(ch)
+ close(ch)
+ }()
+
+ upValue := testutil.ToFloat64(exp.up)
+ if upValue != 0 {
+ t.Errorf("Expected exporter_up=0 on error, got %f", upValue)
+ }
+}
+
+// Example optional test: verifying logs (only if needed).
+func TestExporter_Logs(t *testing.T) {
+ logrus.SetLevel(logrus.DebugLevel)
+ t.Log("Testing Exporter logs at DebugLevel (mock scenario)")
+}
diff --git a/internal/monit/monit.go b/internal/monit/monit.go
new file mode 100644
index 0000000..7f4aea3
--- /dev/null
+++ b/internal/monit/monit.go
@@ -0,0 +1,247 @@
+package monit
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "encoding/xml"
+ "fmt"
+ "golang.org/x/net/html/charset"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/commercetools/monit-exporter/internal/config"
+ "github.com/sirupsen/logrus"
+)
+
+// Monit represents the top-level XML element .
+type Monit struct {
+ XMLName xml.Name `xml:"monit"`
+ Server Server `xml:"server"`
+ Platform Platform `xml:"platform"`
+ Services []Service `xml:"service"`
+}
+
+// Server represents the element in the Monit XML.
+type Server struct {
+ ID string `xml:"id"`
+ Incarnation int64 `xml:"incarnation"`
+ Version string `xml:"version"`
+ Uptime int64 `xml:"uptime"`
+ Poll int `xml:"poll"`
+ StartDelay int `xml:"startdelay"`
+ Localhostname string `xml:"localhostname"`
+ Controlfile string `xml:"controlfile"`
+ HTTPD HTTPD `xml:"httpd"`
+}
+
+// HTTPD represents the element in the Monit XML.
+type HTTPD struct {
+ Address string `xml:"address"`
+ Port int `xml:"port"`
+ SSL int `xml:"ssl"`
+}
+
+// Platform represents the element in the Monit XML.
+type Platform struct {
+ Name string `xml:"name"`
+ Release string `xml:"release"`
+ Version string `xml:"version"`
+ Machine string `xml:"machine"`
+ CPU int `xml:"cpu"`
+ Memory int64 `xml:"memory"`
+ Swap int64 `xml:"swap"`
+}
+
+// Service represents the element in the Monit XML.
+type Service struct {
+ Type int `xml:"type,attr"`
+ Name string `xml:"name"`
+ CollectedSec int64 `xml:"collected_sec"`
+ CollectedUsec int64 `xml:"collected_usec"`
+ Status int `xml:"status"`
+ StatusHint int `xml:"status_hint"`
+ Monitor int `xml:"monitor"`
+ MonitorMode int `xml:"monitormode"`
+ OnReboot int `xml:"onreboot"`
+ PendingAction int `xml:"pendingaction"`
+ Fstype string `xml:"fstype,omitempty"`
+ Fsflags string `xml:"fsflags,omitempty"`
+ Mode string `xml:"mode,omitempty"`
+ UID int `xml:"uid,omitempty"`
+ GID int `xml:"gid,omitempty"`
+ Block *Block `xml:"block,omitempty"`
+ Inode *Inode `xml:"inode,omitempty"`
+ Read string `xml:"read,omitempty"`
+ Write string `xml:"write,omitempty"`
+ Port *Port `xml:"port,omitempty"`
+ System *System `xml:"system,omitempty"`
+ Link *Link `xml:"link,omitempty"`
+}
+
+// Block represents the element under a filesystem service.
+type Block struct {
+ Percent float64 `xml:"percent"`
+ Usage float64 `xml:"usage"`
+ Total float64 `xml:"total"`
+}
+
+// Inode represents the element under a filesystem service.
+type Inode struct {
+ Percent float64 `xml:"percent"`
+ Usage int `xml:"usage"`
+ Total int `xml:"total"`
+}
+
+// Port represents the element, typically for remote host checks.
+type Port struct {
+ Hostname string `xml:"hostname"`
+ Portnumber int `xml:"portnumber"`
+ Request string `xml:"request"`
+ Protocol string `xml:"protocol"`
+ Type string `xml:"type"`
+ Responsetime float64 `xml:"responsetime"`
+ Certificate Certificate `xml:"certificate"`
+}
+
+// Certificate represents the element under .
+type Certificate struct {
+ Valid int `xml:"valid"`
+}
+
+// System represents the element, usually present in type="5" (System) services.
+type System struct {
+ Load Load `xml:"load"`
+ CPU CPU `xml:"cpu"`
+ Memory Memory `xml:"memory"`
+ Swap Swap `xml:"swap"`
+}
+
+// Load represents the element under .
+type Load struct {
+ Avg01 float64 `xml:"avg01"`
+ Avg05 float64 `xml:"avg05"`
+ Avg15 float64 `xml:"avg15"`
+}
+
+// CPU represents the element under .
+type CPU struct {
+ User float64 `xml:"user"`
+ System float64 `xml:"system"`
+ Wait float64 `xml:"wait"`
+}
+
+// Memory represents the element under .
+type Memory struct {
+ Percent float64 `xml:"percent"`
+ Kilobyte int `xml:"kilobyte"`
+}
+
+// Swap represents the element under .
+type Swap struct {
+ Percent float64 `xml:"percent"`
+ Kilobyte int `xml:"kilobyte"`
+}
+
+// Link represents the element under a network service.
+type Link struct {
+ State int `xml:"state"`
+ Speed int64 `xml:"speed"`
+ Duplex int `xml:"duplex"`
+ Download Download `xml:"download"`
+ Upload Upload `xml:"upload"`
+}
+
+// Download represents the element under .
+type Download struct {
+ Packets Packets `xml:"packets"`
+ Bytes Bytes `xml:"bytes"`
+ Errors Errors `xml:"errors"`
+}
+
+// Upload represents the element under .
+type Upload struct {
+ Packets Packets `xml:"packets"`
+ Bytes Bytes `xml:"bytes"`
+ Errors Errors `xml:"errors"`
+}
+
+// Packets represents the element under or .
+type Packets struct {
+ Now int `xml:"now"`
+ Total int `xml:"total"`
+}
+
+// Bytes represents the element under or .
+type Bytes struct {
+ Now int `xml:"now"`
+ Total int `xml:"total"`
+}
+
+// Errors represents the element under or .
+type Errors struct {
+ Now int `xml:"now"`
+ Total int `xml:"total"`
+}
+
+// FetchMonitStatus sends an HTTP GET request to the Monit endpoint and returns the response body.
+func FetchMonitStatus(cfg *config.Config) ([]byte, error) {
+ logrus.Debugf("FetchMonitStatus: MonitScrapeURI=%s, IgnoreSSL=%t", cfg.MonitScrapeURI, cfg.IgnoreSSL)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", cfg.MonitScrapeURI, nil)
+ if err != nil {
+ logrus.Errorf("FetchMonitStatus: failed to create HTTP request: %v", err)
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+ req.SetBasicAuth(cfg.MonitUser, cfg.MonitPassword)
+
+ tr := &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.IgnoreSSL},
+ }
+ client := &http.Client{Transport: tr}
+
+ logrus.Debug("FetchMonitStatus: sending request to Monit")
+ resp, err := client.Do(req)
+ if err != nil {
+ logrus.Errorf("FetchMonitStatus: HTTP request failed: %v", err)
+ return nil, fmt.Errorf("unable to fetch Monit status: %w", err)
+ }
+ defer func() {
+ if cerr := resp.Body.Close(); cerr != nil {
+ logrus.Warnf("FetchMonitStatus: failed to close response body: %v", cerr)
+ }
+ }()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ logrus.Errorf("FetchMonitStatus: non-2xx status code: %d", resp.StatusCode)
+ return nil, fmt.Errorf("monit returned non-2xx status code: %d", resp.StatusCode)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ logrus.Errorf("FetchMonitStatus: failed to read response body: %v", err)
+ return nil, fmt.Errorf("unable to read Monit status: %w", err)
+ }
+ logrus.Debugf("FetchMonitStatus: successfully received response (%d bytes)", len(data))
+ return data, nil
+}
+
+// ParseMonitStatus parses the XML data and returns a Monit struct.
+func ParseMonitStatus(data []byte) (Monit, error) {
+ logrus.Debug("ParseMonitStatus: starting XML parsing")
+ var statusChunk Monit
+ reader := bytes.NewReader(data)
+ decoder := xml.NewDecoder(reader)
+ decoder.CharsetReader = charset.NewReaderLabel
+
+ if err := decoder.Decode(&statusChunk); err != nil {
+ logrus.Errorf("ParseMonitStatus: XML parsing failed: %v", err)
+ return Monit{}, fmt.Errorf("failed to parse Monit XML: %w", err)
+ }
+ logrus.Debugf("ParseMonitStatus: successfully parsed. Services count=%d", len(statusChunk.Services))
+ return statusChunk, nil
+}
diff --git a/internal/monit/monit_test.go b/internal/monit/monit_test.go
new file mode 100644
index 0000000..7095202
--- /dev/null
+++ b/internal/monit/monit_test.go
@@ -0,0 +1,75 @@
+package monit
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/commercetools/monit-exporter/internal/config"
+)
+
+// TestFetchMonitStatus_Success checks if FetchMonitStatus can retrieve mock XML successfully.
+func TestFetchMonitStatus_Success(t *testing.T) {
+ t.Log("Testing FetchMonitStatus with a mock server providing valid XML")
+
+ mockXML := `5.26.0`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = fmt.Fprint(w, mockXML)
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{MonitScrapeURI: server.URL, IgnoreSSL: false}
+ data, err := FetchMonitStatus(cfg)
+ if err != nil {
+ t.Fatalf("FetchMonitStatus returned error: %v", err)
+ }
+ if len(data) == 0 {
+ t.Errorf("Expected non-empty data, got empty")
+ }
+}
+
+// TestFetchMonitStatus_Non2xx checks if FetchMonitStatus returns an error on 400 status code.
+func TestFetchMonitStatus_Non2xx(t *testing.T) {
+ t.Log("Testing FetchMonitStatus with a mock server returning 400 status")
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "some error", http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{MonitScrapeURI: server.URL}
+ _, err := FetchMonitStatus(cfg)
+ if err == nil {
+ t.Fatal("Expected an error for HTTP 400 status, got nil")
+ }
+}
+
+// TestParseMonitStatus_Success verifies parsing a valid Monit XML.
+func TestParseMonitStatus_Success(t *testing.T) {
+ t.Log("Testing ParseMonitStatus with a valid XML string")
+
+ mockXML := `5.26.0rootfs`
+ monitData, err := ParseMonitStatus([]byte(mockXML))
+ if err != nil {
+ t.Fatalf("ParseMonitStatus failed: %v", err)
+ }
+
+ if len(monitData.Services) != 1 {
+ t.Errorf("Expected 1 service, got %d", len(monitData.Services))
+ }
+ if monitData.Services[0].Name != "rootfs" {
+ t.Errorf("Expected service name 'rootfs', got '%s'", monitData.Services[0].Name)
+ }
+}
+
+// TestParseMonitStatus_Error verifies error handling for malformed XML.
+func TestParseMonitStatus_Error(t *testing.T) {
+ t.Log("Testing ParseMonitStatus with malformed XML string")
+
+ invalidXML := `missing closing tags`
+ _, err := ParseMonitStatus([]byte(invalidXML))
+ if err == nil {
+ t.Fatal("Expected an XML parse error, got nil")
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..14dc70b
--- /dev/null
+++ b/main.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+ "github.com/commercetools/monit-exporter/cmd"
+ "github.com/sirupsen/logrus"
+)
+
+// main is the entry point of the Monit Exporter application.
+func main() {
+ logrus.Debug("main() function invoked: calling cmd.Execute()")
+ cmd.Execute()
+ logrus.Debug("main() function completed: cmd.Execute() returned without error")
+}
diff --git a/monit_exporter.go b/monit_exporter.go
deleted file mode 100644
index 5051341..0000000
--- a/monit_exporter.go
+++ /dev/null
@@ -1,221 +0,0 @@
-package main
-
-import (
- "bytes"
- "crypto/tls"
- "encoding/xml"
- "flag"
- "io/ioutil"
- "net/http"
- "sync"
-
- "github.com/prometheus/client_golang/prometheus"
- "github.com/prometheus/log"
- "github.com/spf13/viper"
- "golang.org/x/net/html/charset"
-)
-
-const (
- namespace = "monit" // Prefix for Prometheus metrics.
-)
-
-var configFile = flag.String("conf", "./config.toml", "Configuration file for exporter")
-
-var serviceTypes = map[int]string{
- 0: "filesystem",
- 1: "directory",
- 2: "file",
- 3: "program with pidfile",
- 4: "remote host",
- 5: "system",
- 6: "fifo",
- 7: "program with path",
- 8: "network",
-}
-
-type monitXML struct {
- MonitServices []monitService `xml:"service"`
-}
-
-// Simplified structure of monit check.
-type monitService struct {
- Type int `xml:"type,attr"`
- Name string `xml:"name"`
- Status int `xml:"status"`
- Monitored string `xml:"monitor"`
-}
-
-// Exporter collects monit stats from the given URI and exports them using
-// the prometheus metrics package.
-type Exporter struct {
- config *Config
- mutex sync.RWMutex
- client *http.Client
-
- up prometheus.Gauge
- checkStatus *prometheus.GaugeVec
-}
-
-type Config struct {
- listen_address string
- metrics_path string
- ignore_ssl bool
- monit_scrape_uri string
- monit_user string
- monit_password string
-}
-
-func FetchMonitStatus(c *Config) ([]byte, error) {
- client := &http.Client{
- Transport: &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: c.ignore_ssl},
- },
- }
-
- req, err := http.NewRequest("GET", c.monit_scrape_uri, nil)
- if err != nil {
- log.Errorf("Unable to create request: %v", err)
- }
-
- req.SetBasicAuth(c.monit_user, c.monit_password)
- resp, err := client.Do(req)
- if err != nil {
- log.Error("Unable to fetch monit status")
- return nil, err
- }
- data, err := ioutil.ReadAll(resp.Body)
- if err != nil {
- log.Fatal("Unable to read monit status")
- return nil, err
- }
- defer resp.Body.Close()
- return data, nil
-}
-
-func ParseMonitStatus(data []byte) (monitXML, error) {
- var statusChunk monitXML
- reader := bytes.NewReader(data)
- decoder := xml.NewDecoder(reader)
-
- // Parsing status results to structure
- decoder.CharsetReader = charset.NewReaderLabel
- err := decoder.Decode(&statusChunk)
- return statusChunk, err
-}
-
-func ParseConfig() *Config {
- flag.Parse()
-
- v := viper.New()
-
- v.SetDefault("listen_address", "localhost:9388")
- v.SetDefault("metrics_path", "/metrics")
- v.SetDefault("ignore_ssl", false)
- v.SetDefault("monit_scrape_uri", "http://localhost:2812/_status?format=xml&level=full")
- v.SetDefault("monit_user", "")
- v.SetDefault("monit_password", "")
- v.SetConfigFile(*configFile)
- v.SetConfigType("toml")
- err := v.ReadInConfig() // Find and read the config file
- if err != nil { // Handle errors reading the config file
- log.Printf("Error reading config file: %s. Using defaults.", err)
- }
-
- return &Config{
- listen_address: v.GetString("listen_address"),
- metrics_path: v.GetString("metrics_path"),
- ignore_ssl: v.GetBool("ignore_ssl"),
- monit_scrape_uri: v.GetString("monit_scrape_uri"),
- monit_user: v.GetString("monit_user"),
- monit_password: v.GetString("monit_password"),
- }
-}
-
-// Returns an initialized Exporter.
-func NewExporter(c *Config) (*Exporter, error) {
-
- return &Exporter{
- config: c,
- up: prometheus.NewGauge(prometheus.GaugeOpts{
- Namespace: namespace,
- Name: "exporter_up",
- Help: "Monit status availability",
- }),
- checkStatus: prometheus.NewGaugeVec(prometheus.GaugeOpts{
- Namespace: namespace,
- Name: "exporter_service_check",
- Help: "Monit service check info",
- },
- []string{"check_name", "type", "monitored"},
- ),
- }, nil
-}
-
-// Describe describes all the metrics ever exported by the monit exporter. It
-// implements prometheus.Collector.
-func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
- e.up.Describe(ch)
- e.checkStatus.Describe(ch)
-}
-
-func (e *Exporter) scrape() error {
- data, err := FetchMonitStatus(e.config)
- if err != nil {
- // set "monit_exporter_up" gauge to 0, remove previous metrics from e.checkStatus vector
- e.up.Set(0)
- e.checkStatus.Reset()
- log.Errorf("Error getting monit status: %v", err)
- return err
- } else {
- parsedData, err := ParseMonitStatus(data)
- if err != nil {
- e.up.Set(0)
- e.checkStatus.Reset()
- log.Errorf("Error parsing data from monit: %v", err)
- } else {
- e.up.Set(1)
- // Constructing metrics
- for _, service := range parsedData.MonitServices {
- e.checkStatus.With(prometheus.Labels{"check_name": service.Name, "type": serviceTypes[service.Type], "monitored": service.Monitored}).Set(float64(service.Status))
- }
- }
- return err
- }
-}
-
-// Collect fetches the stats from configured monit location and delivers them
-// as Prometheus metrics. It implements prometheus.Collector.
-func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
- e.mutex.Lock() // Protect metrics from concurrent collects.
- defer e.mutex.Unlock()
- e.checkStatus.Reset()
- e.scrape()
- e.up.Collect(ch)
- e.checkStatus.Collect(ch)
- return
-}
-
-func main() {
-
- config := ParseConfig()
- exporter, err := NewExporter(config)
-
- if err != nil {
- log.Fatal(err)
- }
- prometheus.MustRegister(exporter)
-
- log.Printf("Starting monit_exporter: %s", config.listen_address)
- http.Handle(config.metrics_path, prometheus.Handler())
- http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
- w.Write([]byte(`
- Monit Exporter
-
- Monit Exporter
- Metrics
-
- `))
- })
-
- log.Fatal(http.ListenAndServe(config.listen_address, nil))
-}
diff --git a/monit_exporter_test.go b/monit_exporter_test.go
deleted file mode 100644
index 084871a..0000000
--- a/monit_exporter_test.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package main
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
-
- "fmt"
- "io/ioutil"
- "time"
-)
-
-const (
- monitStatus = `acfbb9e9118e68d3754761a79d3aae1615046052145.23.0136736600fc566edc8b68/opt/monit/etc/monitrc172.17.0.228120Linux4.9.27-moby#1 SMP Thu May 11 04:01:18 UTC 2017x86_64420467681048572fc566edc8b681505209672232150010000.000.000.000.10.10.16.51336280.00`
- monitServiceName = `fc566edc8b68`
- monitUser = `user`
- monitPassword = `password`
-)
-
-func TestMonitStatus(t *testing.T) {
-
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Write([]byte(monitStatus))
- })
- server := httptest.NewServer(handler)
- config := ParseConfig()
- config.monit_scrape_uri = server.URL
- e, err := NewExporter(config)
- if err != nil {
- t.Error("Unexpected error during exporter creation")
- }
- err = e.scrape()
- if err != nil {
- t.Error("Unexpected execution error:", err)
- }
-}
-
-func TestFieldsParsing(t *testing.T) {
- parsedData, err := ParseMonitStatus([]byte(monitStatus))
- if err != nil {
- t.Error("Unable to parse XML:", err)
- }
- if parsedData.MonitServices[0].Name != monitServiceName {
- t.Errorf("want Name %d, have %d.", monitServiceName, parsedData.MonitServices[0].Name)
- }
-}
-
-func TestMonitUnavailable(t *testing.T) {
- mConfig := &Config{
- monit_scrape_uri: "http://localhost:1/status",
- }
- e, err := NewExporter(mConfig)
- if err != nil {
- t.Error("Unexpected error during exporter creation")
- }
- err = e.scrape()
- if err == nil {
- t.Error("Unexpected succsessful execution")
- }
-}
-
-func TestHttpQueryExporter(t *testing.T) {
- go main()
- time.Sleep(50 * time.Millisecond)
- address := "127.0.0.1:9388"
- resp, err := http.Get(fmt.Sprintf("http://%s/metrics", address))
- if err != nil {
- t.Fatal(err)
- }
- b, err := ioutil.ReadAll(resp.Body)
- if err != nil {
- t.Error(err)
- }
- if err := resp.Body.Close(); err != nil {
- t.Error(err)
- }
- if want, have := http.StatusOK, resp.StatusCode; want != have {
- t.Errorf("want /metrics status code %d, have %d. Body:\n%s", want, have, b)
- }
-}
-
-func AuthHandler(w http.ResponseWriter, r *http.Request) {
- user, pass, _ := r.BasicAuth()
- if user == monitUser && pass == monitPassword {
- w.Write([]byte(monitStatus))
-
- } else {
- http.Error(w, "Unauthorized.", 401)
- }
-}
-
-func TestBasicAuth(t *testing.T) {
- handler := http.HandlerFunc(AuthHandler)
- server := httptest.NewServer(handler)
- config := ParseConfig()
- config.monit_scrape_uri = server.URL
- config.monit_user = monitUser
- config.monit_password = monitPassword
- e, err := NewExporter(config)
- if err != nil {
- t.Error("Unexpected error during exporter creation")
- }
- err = e.scrape()
- if err != nil {
- t.Error("Unexpected execution error:", err)
- }
-}
-
-func TestBasicAuthFail(t *testing.T) {
- handler := http.HandlerFunc(AuthHandler)
- server := httptest.NewServer(handler)
- config := ParseConfig()
- config.monit_scrape_uri = server.URL
- config.monit_user = monitUser
- config.monit_password = monitPassword + "qwe"
- e, err := NewExporter(config)
- if err != nil {
- t.Error("Unexpected error during exporter creation")
- }
- err = e.scrape()
- if err == nil {
- t.Error("Unexpected execution success:")
- }
-}