├── .github └── workflows │ ├── build-schema.yaml │ └── release.yaml ├── .gitignore ├── Dockerfile ├── README.md ├── cmd └── kromgo │ ├── init │ ├── configuration │ │ └── configuration.go │ ├── log │ │ └── log.go │ ├── prometheus │ │ └── prometheus.go │ └── server │ │ └── server.go │ └── main.go ├── config.schema.json ├── config.yaml.example ├── go.mod ├── go.sum ├── pkg └── kromgo │ ├── errors.go │ ├── kromgo.go │ ├── utils.go │ └── utils_test.go └── renovate.json /.github/workflows/build-schema.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 3 | name: "YAML Schema" 4 | 5 | on: 6 | push: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build-schema: 11 | name: Build YAML Schema 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | 19 | - name: Setup Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: 1.24.3 23 | 24 | - name: Render schema 25 | run: | 26 | go mod tidy 27 | go run cmd/kromgo/main.go -jsonschema > config.schema.json 28 | 29 | - name: Commit Changes 30 | uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 31 | with: 32 | commit_message: "docs: render json schema" 33 | file_pattern: "config.schema.json" 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: ["main"] 8 | release: 9 | types: ["published"] 10 | 11 | jobs: 12 | build-image: 13 | if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' }} 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Docker meta 23 | id: meta 24 | uses: docker/metadata-action@v5 25 | with: 26 | images: | 27 | ghcr.io/${{ github.repository }} 28 | tags: | 29 | type=semver,pattern={{version}},prefix=v 30 | type=semver,pattern={{major}}.{{minor}},prefix=v 31 | type=semver,pattern={{major}},prefix=v 32 | type=ref,event=branch 33 | type=ref,event=pr 34 | flavor: | 35 | latest=auto 36 | 37 | - name: Set up QEMU 38 | uses: docker/setup-qemu-action@v3 39 | 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v3 42 | 43 | - name: Login to GitHub Container Registry 44 | uses: docker/login-action@v3 45 | with: 46 | registry: ghcr.io 47 | username: ${{ github.actor }} 48 | password: ${{ secrets.GITHUB_TOKEN }} 49 | 50 | - name: Build and Push 51 | uses: docker/build-push-action@v6 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | platforms: linux/amd64,linux/arm64 56 | push: true 57 | tags: ${{ steps.meta.outputs.tags }} 58 | labels: ${{ steps.meta.outputs.labels }} 59 | build-args: | 60 | VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} 61 | REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS build 2 | ARG PKG=github.com/kashalls/kromgo 3 | ARG VERSION=dev 4 | ARG REVISION=dev 5 | WORKDIR /build 6 | COPY . . 7 | RUN go build -ldflags "-s -w -X main.Version=${VERSION} -X main.Gitsha=${REVISION}" ./cmd/kromgo 8 | 9 | 10 | FROM alpine AS fonts 11 | 12 | RUN apk add --no-cache msttcorefonts-installer 13 | RUN update-ms-fonts 14 | 15 | FROM gcr.io/distroless/static-debian12:nonroot 16 | USER nonroot:nonroot 17 | COPY --from=build --chmod=555 /build/kromgo /kromgo/kromgo 18 | COPY --from=fonts --chmod=555 /usr/share/fonts/truetype/msttcorefonts/Verdana.ttf /kromgo/ 19 | EXPOSE 8080/tcp 8888/tcp 20 | WORKDIR /kromgo 21 | LABEL \ 22 | org.opencontainers.image.title="kromgo" \ 23 | org.opencontainers.image.source="https://github.com/kashalls/kromgo" 24 | ENTRYPOINT ["/kromgo/kromgo"] 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kromgo 2 | 3 | A simple go project that allows you to expose prometheus metrics "safely" to a public source. Uses the official prometheus go api client. Better than exposing a grafana image rendering instance to the WWW. 4 | 5 | It allows you to define your own metric names and your own prometheus queries as long as they return a single value at the end. There is config support to allow you to format the response with strings before and after the value. 6 | 7 | You can use [shields.io](https://shields.io) and use either the [Dynamic JSON Badge](https://shields.io/badges/dynamic-json-badge) or the [Endpoint Badge](https://shields.io/badges/endpoint-badge) and add dynamic coloring with ranges you set. 8 | 9 | [Config Example](./config.yaml.example) 10 | [Configuration Structure](./cmd/kromgo/init/configuration/configuration.go) 11 | 12 | - Reads configuration file from `/kromgo/config.yaml` 13 | - Requires `PROMETHEUS_URL` be set in ENV. 14 | - Optional `SERVER_PORT` to change server port. 15 | 16 | ## Performance 17 | 18 | Queries take around 5ms ~ 75ms to complete depending on how many breaks my prometheus server takes. This was running on my [home-cluster](https://github.com/kashalls/home-cluster) and runs 3 instances, so depending on the query YMMV. 19 | 20 | ## Example Request 21 | 22 | ### Endpoint Response 23 | 24 | This format is provided to support Shield.io's [Endpoint Badge](https://shields.io/badges/endpoint-badge) endpoint. 25 | 26 | `HTTP GET localhost:8080/node_cpu_usage` 27 | 28 | ```json 29 | { 30 | "color": "green", 31 | "label": "node_cpu_usage", 32 | "message": "17.5", 33 | "schemaVersion": 1 34 | } 35 | ``` 36 | 37 | ### Raw Response 38 | 39 | `HTTP GET localhost:8080/node_cpu_usage?format=raw` 40 | 41 | ```json 42 | [ 43 | { 44 | "metric": {}, 45 | "value": [ 46 | 1702664619.78, 47 | "17.5" 48 | ] 49 | } 50 | ] 51 | ``` 52 | 53 | ### Badge Response 54 | 55 | Like the `endpoint` format but serves an svg badge with `label` and `message` 56 | 57 | `HTTP GET localhost:8080/node_cpu_usage?format=badge` 58 | 59 | ``` 60 | content-type: image/svg+xml 61 | 0 { 84 | for _, warning := range warnings { 85 | requestLog(r).With(zap.String("warning", warning)).Warn("encountered warnings while executing metric query") 86 | } 87 | } 88 | jsonResult, err := json.Marshal(promResult) 89 | requestLog(r).With(zap.String("result", string(jsonResult))).Debug("query result") 90 | if err != nil { 91 | requestLog(r).With(zap.Error(err)).Error("could not convert query result to json") 92 | HandleError(w, r, requestMetric, "Query Error", http.StatusInternalServerError) 93 | return 94 | } 95 | 96 | if len(jsonResult) <= 0 { 97 | requestLog(r).Error("query returned no results") 98 | HandleError(w, r, requestMetric, "No Data", http.StatusOK) 99 | return 100 | } 101 | 102 | if requestFormat == "raw" { 103 | w.Header().Set("Content-Type", "application/json") 104 | w.Write(jsonResult) 105 | return 106 | } 107 | 108 | prometheusData := promResult.(model.Vector) 109 | log.Debug("prometheus returned data", zap.Any("data", prometheusData)) 110 | 111 | var colorConfig configuration.MetricColor 112 | var response string 113 | if len(prometheusData) > 0 { 114 | resultValue := float64(prometheusData[0].Value) 115 | colorConfig = GetColorConfig(metric.Colors, resultValue) 116 | response = strconv.FormatFloat(resultValue, 'f', -1, 64) 117 | } else { 118 | colorConfig = configuration.MetricColor{} 119 | response = "metric returned no data" 120 | } 121 | 122 | if len(metric.Label) > 0 { 123 | labelValue, err := ExtractLabelValue(prometheusData, metric.Label) 124 | if err != nil { 125 | requestLog(r).With(zap.String("label", metric.Label), zap.Error(err)).Error("label was not found in query result") 126 | HandleError(w, r, requestMetric, "No Data", http.StatusOK) 127 | return 128 | } 129 | response = labelValue 130 | } 131 | if len(colorConfig.ValueOverride) > 0 { 132 | response = colorConfig.ValueOverride 133 | } 134 | 135 | message := metric.Prefix + response + metric.Suffix 136 | 137 | title := metric.Name 138 | if metric.Title != "" { 139 | title = metric.Title 140 | } 141 | 142 | if requestFormat == "badge" { 143 | hex := colorNameToHex(colorConfig.Color) 144 | 145 | w.Header().Set("Content-Type", "image/svg+xml") 146 | 147 | if badgeStyle == "plastic" { 148 | w.Write(h.BadgeGenerator.GeneratePlastic(title, message, hex)) 149 | return 150 | } 151 | if badgeStyle == "flat-square" { 152 | w.Write(h.BadgeGenerator.GenerateFlatSquare(title, message, hex)) 153 | return 154 | } 155 | //if badgeStyle == "flat" || badgeStyle == "" { 156 | // w.Write(h.BadgeGenerator.GenerateFlat(title, message, hex)) 157 | // return 158 | //} 159 | 160 | w.Write(h.BadgeGenerator.GenerateFlat(title, message, hex)) 161 | return 162 | } 163 | 164 | data := map[string]interface{}{ 165 | "schemaVersion": 1, 166 | "label": title, 167 | "message": message, 168 | } 169 | 170 | if colorConfig.Color != "" { 171 | data["color"] = colorConfig.Color 172 | } 173 | 174 | jsonResponse, err := json.Marshal(data) 175 | if err != nil { 176 | requestLog(r).With(zap.Error(err)).Error("error converting data to json response") 177 | HandleError(w, r, requestMetric, "Error", http.StatusInternalServerError) 178 | return 179 | } 180 | 181 | w.Header().Set("Content-Type", "application/json") 182 | w.Write(jsonResponse) 183 | } 184 | 185 | func requestLog(r *http.Request) *zap.Logger { 186 | requestMetric := chi.URLParam(r, "metric") 187 | requestFormat := r.URL.Query().Get("format") 188 | 189 | return log.With(zap.String("req_method", r.Method), zap.String("req_path", r.URL.Path), zap.String("metric", requestMetric), zap.String("format", requestFormat)) 190 | } 191 | 192 | func colorNameToHex(colorName string) string { 193 | if strings.HasPrefix(colorName, "#") { 194 | return colorName 195 | } 196 | 197 | switch colorName { 198 | case "": 199 | return badge.COLOR_BLUE 200 | case "blue": 201 | return badge.COLOR_BLUE 202 | case "brightgreen": 203 | return badge.COLOR_BRIGHTGREEN 204 | case "green": 205 | return badge.COLOR_GREEN 206 | case "grey": 207 | return badge.COLOR_GREY 208 | case "lightgrey": 209 | return badge.COLOR_LIGHTGREY 210 | case "orange": 211 | return badge.COLOR_ORANGE 212 | case "red": 213 | return badge.COLOR_RED 214 | case "yellow": 215 | return badge.COLOR_YELLOW 216 | case "yellowgreen": 217 | return badge.COLOR_YELLOWGREEN 218 | case "success": 219 | return badge.COLOR_SUCCESS 220 | case "important": 221 | return badge.COLOR_IMPORTANT 222 | case "critical": 223 | return badge.COLOR_CRITICAL 224 | case "informational": 225 | return badge.COLOR_INFORMATIONAL 226 | case "inactive": 227 | return badge.COLOR_INACTIVE 228 | default: 229 | return badge.COLOR_GREEN 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /pkg/kromgo/utils.go: -------------------------------------------------------------------------------- 1 | package kromgo 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" 7 | "github.com/prometheus/common/model" 8 | ) 9 | 10 | func GetColorConfig(colors []configuration.MetricColor, value float64) configuration.MetricColor { 11 | for _, colorConfig := range colors { 12 | if value >= colorConfig.Min && value <= colorConfig.Max { 13 | return colorConfig 14 | } 15 | } 16 | 17 | // MetricColors is enabled, but the value does not have a corresponding value to it. 18 | // We return a default value here only if the result value falls outside the range. 19 | return configuration.MetricColor{ 20 | Min: value, 21 | Max: value, 22 | } 23 | } 24 | 25 | func ExtractLabelValue(vector model.Vector, labelName string) (string, error) { 26 | // Extract label value from the first sample of the result 27 | if len(vector) > 0 { 28 | // Check if the label exists in the first sample 29 | if val, ok := vector[0].Metric[model.LabelName(labelName)]; ok { 30 | return string(val), nil 31 | } 32 | } 33 | 34 | // If label not found, return an error 35 | return "", fmt.Errorf("label '%s' not found in the query result", labelName) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/kromgo/utils_test.go: -------------------------------------------------------------------------------- 1 | package kromgo 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kashalls/kromgo/cmd/kromgo/init/configuration" 7 | "github.com/prometheus/common/model" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetColorConfig_MatchingRange(t *testing.T) { 12 | colors := []configuration.MetricColor{ 13 | {Min: 0, Max: 10, Color: "blue", ValueOverride: "low"}, 14 | {Min: 11, Max: 20, Color: "green", ValueOverride: "medium"}, 15 | {Min: 21, Max: 30, Color: "red", ValueOverride: "high"}, 16 | } 17 | 18 | value := 15.0 19 | 20 | result := GetColorConfig(colors, value) 21 | 22 | expected := configuration.MetricColor{Min: 11, Max: 20, Color: "green", ValueOverride: "medium"} 23 | assert.Equal(t, expected, result) 24 | } 25 | 26 | func TestGetColorConfig_ExactMatch(t *testing.T) { 27 | colors := []configuration.MetricColor{ 28 | {Min: 0, Max: 10, Color: "blue"}, 29 | {Min: 10, Max: 20, Color: "green"}, 30 | } 31 | 32 | value := 10.0 33 | 34 | result := GetColorConfig(colors, value) 35 | 36 | expected := configuration.MetricColor{Min: 10, Max: 20, Color: "green"} 37 | assert.Equal(t, expected, result) 38 | } 39 | 40 | func TestGetColorConfig_NoMatch(t *testing.T) { 41 | colors := []configuration.MetricColor{ 42 | {Min: 0, Max: 10, Color: "blue"}, 43 | {Min: 11, Max: 20, Color: "green"}, 44 | } 45 | 46 | value := 25.0 47 | 48 | result := GetColorConfig(colors, value) 49 | 50 | expected := configuration.MetricColor{Min: 25, Max: 25} 51 | assert.Equal(t, expected, result) 52 | } 53 | 54 | func TestGetColorConfig_EmptyColors(t *testing.T) { 55 | colors := []configuration.MetricColor{} 56 | 57 | value := 10.0 58 | 59 | result := GetColorConfig(colors, value) 60 | 61 | expected := configuration.MetricColor{Min: 10, Max: 10} 62 | assert.Equal(t, expected, result) 63 | } 64 | 65 | func TestGetColorConfig_ValueBelowMin(t *testing.T) { 66 | colors := []configuration.MetricColor{ 67 | {Min: 10, Max: 20, Color: "green"}, 68 | {Min: 21, Max: 30, Color: "red"}, 69 | } 70 | 71 | value := 5.0 72 | 73 | result := GetColorConfig(colors, value) 74 | 75 | expected := configuration.MetricColor{Min: 5, Max: 5} 76 | assert.Equal(t, expected, result) 77 | } 78 | 79 | func TestGetColorConfig_ValueAboveMax(t *testing.T) { 80 | colors := []configuration.MetricColor{ 81 | {Min: 0, Max: 10, Color: "blue"}, 82 | {Min: 11, Max: 20, Color: "green"}, 83 | } 84 | 85 | value := 25.0 86 | 87 | result := GetColorConfig(colors, value) 88 | 89 | expected := configuration.MetricColor{Min: 25, Max: 25} 90 | assert.Equal(t, expected, result) 91 | } 92 | 93 | func TestExtractLabelValue_LabelExists(t *testing.T) { 94 | vector := model.Vector{ 95 | &model.Sample{ 96 | Metric: model.Metric{ 97 | "label1": "value1", 98 | "label2": "value2", 99 | }, 100 | }, 101 | } 102 | 103 | labelName := "label1" 104 | expectedValue := "value1" 105 | 106 | value, err := ExtractLabelValue(vector, labelName) 107 | 108 | assert.NoError(t, err) 109 | assert.Equal(t, expectedValue, value) 110 | } 111 | 112 | func TestExtractLabelValue_LabelDoesNotExist(t *testing.T) { 113 | vector := model.Vector{ 114 | &model.Sample{ 115 | Metric: model.Metric{ 116 | "label1": "value1", 117 | }, 118 | }, 119 | } 120 | 121 | labelName := "label2" 122 | 123 | value, err := ExtractLabelValue(vector, labelName) 124 | 125 | assert.Error(t, err) 126 | assert.Equal(t, "", value) 127 | assert.Equal(t, "label 'label2' not found in the query result", err.Error()) 128 | } 129 | 130 | func TestExtractLabelValue_EmptyVector(t *testing.T) { 131 | vector := model.Vector{} 132 | labelName := "label1" 133 | 134 | value, err := ExtractLabelValue(vector, labelName) 135 | 136 | assert.Error(t, err) 137 | assert.Equal(t, "", value) 138 | assert.Equal(t, "label 'label1' not found in the query result", err.Error()) 139 | } 140 | 141 | func TestExtractLabelValue_LabelEmptyValue(t *testing.T) { 142 | vector := model.Vector{ 143 | &model.Sample{ 144 | Metric: model.Metric{ 145 | "label1": "", // Empty string value for the label 146 | }, 147 | }, 148 | } 149 | 150 | labelName := "label1" 151 | expectedValue := "" 152 | 153 | value, err := ExtractLabelValue(vector, labelName) 154 | 155 | assert.NoError(t, err) 156 | assert.Equal(t, expectedValue, value) 157 | } 158 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | --------------------------------------------------------------------------------