Skip to content

Commit

Permalink
Upload code
Browse files Browse the repository at this point in the history
  • Loading branch information
solodkiy committed Sep 29, 2024
1 parent 88ca48c commit 0b4b898
Show file tree
Hide file tree
Showing 21 changed files with 3,921 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/.idea
/vendor
/gauge-exporter
/e2e_tests/.idea
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test:
go test -v ./... && go build && cd ./e2e_tests && vendor/bin/phpunit
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Gauge Exporter

Gauge Exporter is a Prometheus exporter written in Go that allows you to expose custom gauge metrics to Prometheus.
It provides a simple HTTP API for inputting metrics and serves them in Prometheus format.

This exporter is specifically designed for use with stateless languages and environments where it is not feasible to maintain metric state in memory, such as in PHP.
In these scenarios, Gauge Exporter provides a way to externally store and aggregate metrics that can't be kept in application memory between requests.
If your application is written in a language that allows for easy in-memory state management, you might find other Prometheus client libraries more suitable for direct instrumentation.

Gauge Exporter is specifically designed for gauge metrics and does not support counter metrics.
It is optimized for use cases where the metric value can increase, decrease, or be set to a specific point.
If you need to work with cumulative counters that only increase over time, this exporter may not be suitable for your needs. Please, consider StatsD Exporter or Pushgateway.

## Comparison

### Gauge Exporter vs Prometheus Pushgateway
Gauge Exporter offers two key advantages over Prometheus Pushgateway.
First, it automatically sets unprovided metric labels to zero, simplifying metric reporting with varying label sets.
Second, it supports Time-to-Live (TTL) based metric expiration, allowing automatic cleanup of stale data.
These features make Gauge Exporter particularly suitable for scenarios with time-sensitive metrics and dynamic labeling requirements, while Pushgateway remains ideal for batch jobs and situations requiring full control over metric persistence.

## Usage

### Running the Exporter

To run the Gauge Exporter, use the following command:

```bash
./gauge-exporter --listen=0.0.0.0:8181
```

You can customize the listening address and port using the `--listen` flag.

### Endpoints

1. `/metrics`: Prometheus metrics endpoint
2. `/gauge/{metric_name}`: Input endpoint for gauge metrics
3. `/version`: Returns the version of the exporter

### Inputting MetricBag

To input a metric, send a PUT request to `/gauge/{metric_name}` with the following JSON payload:

```json
{
"ttl": 300,
"data": [
{
"labels": {
"label1": "value1",
"label2": "value2"
},
"value": 42.0
}
],
"system_labels": {
"sys_label1": "sys_value1"
}
}
```

- `ttl`: Time-to-live in seconds for the metric
- `data`: Array of metric data points
- `system_labels`: Labels applied to all data points in this request

### Querying Metrics

To query the metrics, send a GET request to the `/metrics` endpoint. This will return all the metrics in Prometheus format.

## Built-in Metrics

The exporter provides the following built-in metrics:

- `gauge_exporter_metric_lines_total`: Total number of metric lines stored
- `gauge_exporter_metrics_requests_total`: Total number of metric requests, labeled by status (success/failed)

## Clients
TBA
165 changes: 165 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package app

import (
"bytes"
"encoding/json"
"gauge-exporter/storage"
"io"
"maps"
"net/http"
"strings"
"time"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)

type App struct {
storage *storage.MetricsStorage
listenAddr string
version string
metricsTotal prometheus.Gauge
requestsCount *prometheus.CounterVec
}

func NewApp(storage *storage.MetricsStorage, listenAddr string, version string) *App {
return &App{
storage: storage,
listenAddr: listenAddr,
version: version,
metricsTotal: prometheus.NewGauge(
prometheus.GaugeOpts{Name: "gauge_exporter_metric_lines_total"},
),
requestsCount: prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "gauge_exporter_metrics_requests_total"},
[]string{"status"},
),
}
}

func (app *App) Run() {
mux := http.NewServeMux()
mux.HandleFunc("/metrics", app.outputPrometheusHandler)
mux.HandleFunc("/gauge/", app.inputMetricsHandler)
mux.HandleFunc("/version", app.versionHandler)

server := &http.Server{
Addr: app.listenAddr,
Handler: mux,
ReadHeaderTimeout: 3 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}

err := server.ListenAndServe()
if err != nil {
panic(err)
}
}

func (app *App) outputPrometheusHandler(w http.ResponseWriter, r *http.Request) {
promRegistry := prometheus.NewRegistry()

for _, metricName := range app.storage.GetAllMetricsNames() {
for _, line := range app.storage.GetMetricLines(metricName) {
if app.storage.IsExpired(metricName) {
app.storage.Delete(metricName)
continue
}

normalizedName := strings.ReplaceAll(metricName, ".", "_")

m := prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: normalizedName}, line.GetSortedLabelsKeys())
m.With(line.GetLabels()).Set(line.Value)

_ = promRegistry.Register(uncheckedCollector{m})
}
}

app.metricsTotal.Set(float64(app.getTotalStorageLines()))

_ = promRegistry.Register(app.metricsTotal)
_ = promRegistry.Register(app.requestsCount)

promhttp.HandlerFor(promRegistry, promhttp.HandlerOpts{}).ServeHTTP(w, r)
}

func (app *App) inputMetricsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
app.incFailedRequestsCount()
return
}

parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 {
http.Error(w, "Invalid URL", http.StatusBadRequest)
app.incFailedRequestsCount()
return
}
metricName := parts[2]
if len(metricName) == 0 {
http.Error(w, "Invalid Metric Name", http.StatusBadRequest)
app.incFailedRequestsCount()
return
}

var buf bytes.Buffer
_, err := io.Copy(&buf, r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusInternalServerError)
app.incFailedRequestsCount()
return
}

var data inputDto
if err := json.Unmarshal(buf.Bytes(), &data); err != nil {
http.Error(w, "Error parsing JSON", http.StatusBadRequest)
app.incFailedRequestsCount()
return
}

if data.TTL == 0 || data.Data == nil {
http.Error(w, "Incorrect input format", http.StatusBadRequest)
app.incFailedRequestsCount()
return
}

metricLines := make([]*storage.MetricLine, 0, len(data.Data))
for _, v := range data.Data {
line := storage.NewMetricLine(v.Labels, v.Value)
if line.HasAnyLabel(data.SystemLabels) {
http.Error(w, "Metric labels must not contain any of system_labels", http.StatusBadRequest)
app.incFailedRequestsCount()
return
}

maps.Copy(v.Labels, data.SystemLabels)

metricLines = append(metricLines, line)
}

app.storage.UpdateMetricLines(metricName, data.SystemLabels, metricLines, time.Duration(data.TTL)*time.Second)
app.incSuccessfulRequestsCount()
}

func (app *App) versionHandler(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(app.version))
}

func (app *App) getTotalStorageLines() int {
total := 0
for _, metricName := range app.storage.GetAllMetricsNames() {
total += len(app.storage.GetMetricLines(metricName))
}

return total
}

func (app *App) incFailedRequestsCount() {
app.requestsCount.WithLabelValues("failed").Inc()
}

func (app *App) incSuccessfulRequestsCount() {
app.requestsCount.WithLabelValues("success").Inc()
}
12 changes: 12 additions & 0 deletions app/custom_collector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package app

import "github.com/prometheus/client_golang/prometheus"

type uncheckedCollector struct {
c prometheus.Collector
}

func (u uncheckedCollector) Describe(_ chan<- *prometheus.Desc) {}
func (u uncheckedCollector) Collect(ch chan<- prometheus.Metric) {
u.c.Collect(ch)
}
12 changes: 12 additions & 0 deletions app/dto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package app

type labelsValuesDto struct {
Labels map[string]string `json:"labels"`
Value float64 `json:"value"`
}

type inputDto struct {
TTL int `json:"ttl"`
Data []labelsValuesDto `json:"data"`
SystemLabels map[string]string `json:"system_labels"`
}
2 changes: 2 additions & 0 deletions e2e_tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor
/.phpunit.cache
30 changes: 30 additions & 0 deletions e2e_tests/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "belkacar/gauge-exporter-tests",
"license": "proprietary",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": "~8.3.0",
"ext-curl": "*",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.8",
"phpunit/phpunit": "~11.1.0",
"symfony/process": "^7.0",
"belkacar/gauge-exporter-client": "0.2.0"
},
"repositories": {
"belkacar": {
"type": "composer",
"url": "https://gitlab.belkacar.ru/api/v4/group/php-backend/-/packages/composer/packages.json"
}
},
"autoload": {
"psr-4": {
"BelkaCar\\GaugeExporterTests\\": "tests"
}
},
"config": {
"sort-packages": true
}
}
Loading

0 comments on commit 0b4b898

Please sign in to comment.