You've already forked prometheus-fail2ban-exporter
							
							feat: support for textfile metrics (#13)
Add support for collecting arbitrary metrics from a textfile as well as metrics collected from fail2ban. This allows other data to be exported along with the fail2ban metrics (e.g. instance metadata). Update the docker image to allow mounting a folder with a collection of metric files to be exported. Only files ending in `.prom` with be read. Update project README with the new functionality.
This commit is contained in:
		
							
								
								
									
										25
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								README.md
									
									
									
									
									
								
							| @@ -52,6 +52,10 @@ $ fail2ban-prometheus-exporter -h | |||||||
|         path to the fail2ban server socket |         path to the fail2ban server socket | ||||||
|   -version |   -version | ||||||
|         show version info and exit |         show version info and exit | ||||||
|  |   -collector.textfile | ||||||
|  |         enable the textfile collector | ||||||
|  |   -collector.textfile.directory string | ||||||
|  |         directory to read text files with metrics from | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| **Example** | **Example** | ||||||
| @@ -240,3 +244,24 @@ fail2ban_errors{type="db"} 0 | |||||||
| # TYPE fail2ban_up gauge | # TYPE fail2ban_up gauge | ||||||
| fail2ban_up 1 | fail2ban_up 1 | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ### 4.3. Textfile Metrics | ||||||
|  |  | ||||||
|  | For more flexibility the exporter also allows exporting metrics collected from a text file. | ||||||
|  |  | ||||||
|  | To enable textfile metrics: | ||||||
|  | 1. Enable the collector with `-collector.textfile=true` | ||||||
|  | 2. Provide the directory to read files from with the `-collector.textfile.directory` flag | ||||||
|  |  | ||||||
|  | Metrics collected from these files will be exposed directly alongside the other metrics without any additional processing. | ||||||
|  | This means that it is the responsibility of the file creator to ensure the format is correct. | ||||||
|  |  | ||||||
|  | By exporting textfile metrics an extra metric is also exported with an error count for each file: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | # HELP textfile_error Checks for errors while reading text files | ||||||
|  | # TYPE textfile_error gauge | ||||||
|  | textfile_error{path="file.prom"} 0 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | **NOTE:** Any file not ending with `.prom` will be ignored. | ||||||
|   | |||||||
| @@ -5,6 +5,8 @@ | |||||||
|  |  | ||||||
| db_path=/app/fail2ban.sqlite3 | db_path=/app/fail2ban.sqlite3 | ||||||
| socket_path=/var/run/fail2ban/fail2ban.sock | socket_path=/var/run/fail2ban/fail2ban.sock | ||||||
|  | textfile_dir=/app/textfile/ | ||||||
|  | textfile_enabled=false | ||||||
|  |  | ||||||
| # Blank out the file paths if they do not exist - a hacky way to only use these files if they were mounted into the container. | # Blank out the file paths if they do not exist - a hacky way to only use these files if they were mounted into the container. | ||||||
| if [ ! -f "$db_path" ]; then | if [ ! -f "$db_path" ]; then | ||||||
| @@ -13,9 +15,14 @@ fi | |||||||
| if [ ! -S "$socket_path" ]; then | if [ ! -S "$socket_path" ]; then | ||||||
|     socket_path="" |     socket_path="" | ||||||
| fi | fi | ||||||
|  | if [ -d $textfile_dir ]; then | ||||||
|  |     textfile_enabled=true | ||||||
|  | fi | ||||||
|  |  | ||||||
| # Start the exporter (use exec to support graceful shutdown) | # Start the exporter (use exec to support graceful shutdown) | ||||||
| # Inspired by: https://akomljen.com/stopping-docker-containers-gracefully/ | # Inspired by: https://akomljen.com/stopping-docker-containers-gracefully/ | ||||||
| exec /app/fail2ban-prometheus-exporter \ | exec /app/fail2ban-prometheus-exporter \ | ||||||
|     -db "$db_path" \ |     -db "$db_path" \ | ||||||
|     -socket "$socket_path" |     -socket "$socket_path" \ | ||||||
|  |     -collector.textfile=$textfile_enabled \ | ||||||
|  |     -collector.textfile.directory="$textfile_dir" | ||||||
|   | |||||||
| @@ -12,10 +12,12 @@ const ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
| type AppSettings struct { | type AppSettings struct { | ||||||
| 	VersionMode        bool | 	VersionMode          bool | ||||||
| 	MetricsPort        int | 	MetricsPort          int | ||||||
| 	Fail2BanDbPath     string | 	Fail2BanDbPath       string | ||||||
| 	Fail2BanSocketPath string | 	Fail2BanSocketPath   string | ||||||
|  | 	FileCollectorPath    string | ||||||
|  | 	FileCollectorEnabled bool | ||||||
| } | } | ||||||
|  |  | ||||||
| func Parse() *AppSettings { | func Parse() *AppSettings { | ||||||
| @@ -24,6 +26,8 @@ func Parse() *AppSettings { | |||||||
| 	flag.IntVar(&appSettings.MetricsPort, "port", 9191, "port to use for the metrics server") | 	flag.IntVar(&appSettings.MetricsPort, "port", 9191, "port to use for the metrics server") | ||||||
| 	flag.StringVar(&appSettings.Fail2BanDbPath, "db", "", "path to the fail2ban sqlite database (deprecated)") | 	flag.StringVar(&appSettings.Fail2BanDbPath, "db", "", "path to the fail2ban sqlite database (deprecated)") | ||||||
| 	flag.StringVar(&appSettings.Fail2BanSocketPath, "socket", "", "path to the fail2ban server socket") | 	flag.StringVar(&appSettings.Fail2BanSocketPath, "socket", "", "path to the fail2ban server socket") | ||||||
|  | 	flag.BoolVar(&appSettings.FileCollectorEnabled, "collector.textfile", false, "enable the textfile collector") | ||||||
|  | 	flag.StringVar(&appSettings.FileCollectorPath, "collector.textfile.directory", "", "directory to read text files with metrics from") | ||||||
|  |  | ||||||
| 	flag.Parse() | 	flag.Parse() | ||||||
| 	appSettings.validateFlags() | 	appSettings.validateFlags() | ||||||
| @@ -42,6 +46,10 @@ func (settings *AppSettings) validateFlags() { | |||||||
| 				minServerPort, maxServerPort, settings.MetricsPort) | 				minServerPort, maxServerPort, settings.MetricsPort) | ||||||
| 			flagsValid = false | 			flagsValid = false | ||||||
| 		} | 		} | ||||||
|  | 		if settings.FileCollectorEnabled && settings.FileCollectorPath == "" { | ||||||
|  | 			fmt.Printf("file collector directory path must not be empty if collector enabled\n") | ||||||
|  | 			flagsValid = false | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| 	if !flagsValid { | 	if !flagsValid { | ||||||
| 		flag.Usage() | 		flag.Usage() | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ package main | |||||||
| import ( | import ( | ||||||
| 	"fail2ban-prometheus-exporter/cfg" | 	"fail2ban-prometheus-exporter/cfg" | ||||||
| 	"fail2ban-prometheus-exporter/export" | 	"fail2ban-prometheus-exporter/export" | ||||||
|  | 	"fail2ban-prometheus-exporter/textfile" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"log" | 	"log" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| @@ -43,6 +44,11 @@ func rootHtmlHandler(w http.ResponseWriter, r *http.Request) { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func metricHandler(w http.ResponseWriter, r *http.Request, collector *textfile.Collector) { | ||||||
|  | 	promhttp.Handler().ServeHTTP(w, r) | ||||||
|  | 	collector.WriteTextFileMetrics(w, r) | ||||||
|  | } | ||||||
|  |  | ||||||
| func main() { | func main() { | ||||||
| 	appSettings := cfg.Parse() | 	appSettings := cfg.Parse() | ||||||
| 	if appSettings.VersionMode { | 	if appSettings.VersionMode { | ||||||
| @@ -55,8 +61,13 @@ func main() { | |||||||
| 		exporter := export.NewExporter(appSettings, version) | 		exporter := export.NewExporter(appSettings, version) | ||||||
| 		prometheus.MustRegister(exporter) | 		prometheus.MustRegister(exporter) | ||||||
|  |  | ||||||
|  | 		textFileCollector := textfile.NewCollector(appSettings) | ||||||
|  | 		prometheus.MustRegister(textFileCollector) | ||||||
|  |  | ||||||
| 		http.HandleFunc("/", rootHtmlHandler) | 		http.HandleFunc("/", rootHtmlHandler) | ||||||
| 		http.Handle(metricsPath, promhttp.Handler()) | 		http.HandleFunc(metricsPath, func(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 			metricHandler(w, r, textFileCollector) | ||||||
|  | 		}) | ||||||
| 		log.Printf("metrics available at '%s'", metricsPath) | 		log.Printf("metrics available at '%s'", metricsPath) | ||||||
|  |  | ||||||
| 		svrErr := make(chan error) | 		svrErr := make(chan error) | ||||||
|   | |||||||
							
								
								
									
										48
									
								
								src/textfile/collector.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/textfile/collector.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | |||||||
|  | package textfile | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fail2ban-prometheus-exporter/cfg" | ||||||
|  | 	"github.com/prometheus/client_golang/prometheus" | ||||||
|  | 	"log" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type Collector struct { | ||||||
|  | 	enabled    bool | ||||||
|  | 	folderPath string | ||||||
|  | 	fileMap    map[string]*fileData | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type fileData struct { | ||||||
|  | 	readErrors   int | ||||||
|  | 	fileName     string | ||||||
|  | 	fileContents []byte | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewCollector(appSettings *cfg.AppSettings) *Collector { | ||||||
|  | 	collector := &Collector{ | ||||||
|  | 		enabled:    appSettings.FileCollectorEnabled, | ||||||
|  | 		folderPath: appSettings.FileCollectorPath, | ||||||
|  | 		fileMap:    make(map[string]*fileData), | ||||||
|  | 	} | ||||||
|  | 	if collector.enabled { | ||||||
|  | 		log.Printf("collector.textfile directory: %s", collector.folderPath) | ||||||
|  | 	} | ||||||
|  | 	return collector | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *Collector) Describe(ch chan<- *prometheus.Desc) { | ||||||
|  | 	if c.enabled { | ||||||
|  | 		ch <- metricReadError | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *Collector) Collect(ch chan<- prometheus.Metric) { | ||||||
|  | 	if c.enabled { | ||||||
|  | 		c.collectFileContents() | ||||||
|  | 		c.collectFileErrors(ch) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *Collector) appendErrorForPath(path string) { | ||||||
|  | 	c.fileMap[path].readErrors++ | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								src/textfile/file.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/textfile/file.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | package textfile | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"github.com/prometheus/client_golang/prometheus" | ||||||
|  | 	"io/ioutil" | ||||||
|  | 	"log" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"strings" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | const namespace = "textfile" | ||||||
|  |  | ||||||
|  | var ( | ||||||
|  | 	metricReadError = prometheus.NewDesc( | ||||||
|  | 		prometheus.BuildFQName(namespace, "", "error"), | ||||||
|  | 		"Checks for errors while reading text files", | ||||||
|  | 		[]string{"path"}, nil, | ||||||
|  | 	) | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func (c *Collector) collectFileContents() { | ||||||
|  | 	files, err := ioutil.ReadDir(c.folderPath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("error reading directory '%s': %v", c.folderPath, err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, file := range files { | ||||||
|  | 		fileName := file.Name() | ||||||
|  | 		if !strings.HasSuffix(strings.ToLower(fileName), ".prom") { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		c.fileMap[fileName] = &fileData{ | ||||||
|  | 			readErrors: 0, | ||||||
|  | 			fileName:   fileName, | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		fullPath := filepath.Join(c.folderPath, fileName) | ||||||
|  | 		content, err := ioutil.ReadFile(fullPath) | ||||||
|  | 		if err != nil { | ||||||
|  | 			c.appendErrorForPath(fileName) | ||||||
|  | 			log.Printf("error reading contents of file '%s': %v", fileName, err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		c.fileMap[fileName].fileContents = content | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (c *Collector) collectFileErrors(ch chan<- prometheus.Metric) { | ||||||
|  | 	for _, f := range c.fileMap { | ||||||
|  | 		ch <- prometheus.MustNewConstMetric( | ||||||
|  | 			metricReadError, prometheus.GaugeValue, float64(f.readErrors), f.fileName, | ||||||
|  | 		) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										20
									
								
								src/textfile/writer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/textfile/writer.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | package textfile | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"log" | ||||||
|  | 	"net/http" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func (c *Collector) WriteTextFileMetrics(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	if !c.enabled { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, f := range c.fileMap { | ||||||
|  | 		_, err := w.Write(f.fileContents) | ||||||
|  | 		if err != nil { | ||||||
|  | 			c.appendErrorForPath(f.fileName) | ||||||
|  | 			log.Printf("error writing file contents to response writer '%s': %v", f.fileName, err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user
	 Hector
					Hector