Skip to content

feat(metrics): add support for custom labels in Prometheus metrics #979

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Aug 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f045b90
feat: add dynamic labels support for Prometheus metrics
appleboy Jan 29, 2025
44d1796
Merge branch 'master' into metrics
appleboy Jan 29, 2025
52e3dfb
refactor: refactor pointer conversion utility and update related tests
appleboy Jan 29, 2025
d109332
refactor: refactor utility functions and improve test coverage
appleboy Jan 30, 2025
8c25fef
Merge branch 'master' into metrics
appleboy Feb 3, 2025
cc9788e
missing labels parameter
appleboy Feb 3, 2025
9507b33
Merge branch 'master' into metrics
TwiN Feb 6, 2025
85c5e73
refactor: reorder parameters in metrics-related functions and tests
appleboy Feb 6, 2025
7700358
Merge branch 'master' into metrics
appleboy Feb 13, 2025
fc6fe3c
Update main.go
TwiN Feb 17, 2025
32f76a6
Update config/config.go
TwiN Feb 17, 2025
1d754ff
Merge branch 'master' into metrics
appleboy Feb 20, 2025
f36d3c1
Merge branch 'master' into metrics
appleboy Mar 31, 2025
09a8112
Merge branch 'master' into metrics
appleboy Apr 2, 2025
cf9a4d4
docs: improve documentation formatting, examples, and readability
appleboy Apr 2, 2025
fe23cc9
docs: enhance custom labels support in Prometheus metrics
appleboy Apr 2, 2025
6e130d5
Merge branch 'master' into metrics
appleboy Apr 4, 2025
24d615f
Merge branch 'master' into metrics
appleboy Apr 11, 2025
cd0b48a
Merge branch 'master' into metrics
appleboy Apr 15, 2025
e331d8d
Merge branch 'master' into metrics
appleboy Jul 31, 2025
98d314b
refactor: rename and refactor metric labels to use ExtraLabels
appleboy Jul 31, 2025
faa2003
refactor: refactor parameter order for monitor and execute for consis…
appleboy Aug 1, 2025
4d97299
Merge branch 'master' into metrics
appleboy Aug 1, 2025
6d11cbe
test: improve initialization and labeling of Prometheus metrics
appleboy Aug 1, 2025
991c1ef
test: improve Prometheus metrics testing for extra label handling
appleboy Aug 1, 2025
a38bc42
Merge branch 'master' into metrics
appleboy Aug 2, 2025
c302362
refactor: refactor metrics to support custom Prometheus registries
appleboy Aug 3, 2025
7ae5765
Revert README.md to a previous version
appleboy Aug 4, 2025
a0c3d9e
docs: document support for custom metric labels in endpoints
appleboy Aug 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [OIDC](#oidc)
- [TLS Encryption](#tls-encryption)
- [Metrics](#metrics)
- [Custom Labels](#custom-labels)
- [Connectivity](#connectivity)
- [Remote instances (EXPERIMENTAL)](#remote-instances-experimental)
- [Deployment](#deployment)
Expand Down Expand Up @@ -1949,6 +1950,23 @@ endpoint on the same port your application is configured to run on (`web.port`).

See [examples/docker-compose-grafana-prometheus](.examples/docker-compose-grafana-prometheus) for further documentation as well as an example.

#### Custom Labels

Added a Labels field to the Config and Endpoint structs to support key-value pairs for metrics. Updated the Prometheus metrics initialization to include dynamic labels from the configuration. See the example below:

```yaml
endpoints:
- name: front-end
group: core
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 150"
labels:
environment: staging
```

### Connectivity
| Parameter | Description | Default |
Expand Down Expand Up @@ -2183,7 +2201,7 @@ This works for SCTP based application.


### Monitoring a WebSocket endpoint
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints:
By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level:
```yaml
endpoints:
- name: example
Expand Down
3 changes: 2 additions & 1 deletion api/external_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
)

func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
extraLabels := cfg.GetUniqueExtraMetricLabels()
return func(c *fiber.Ctx) error {
// Check if the success query parameter is present
success, exists := c.Queries()["success"]
Expand Down Expand Up @@ -74,7 +75,7 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
}
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result)
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
}
// Return the result
return c.Status(200).SendString("")
Expand Down
19 changes: 19 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ type Config struct {
lastFileModTime time.Time // last modification time
}

// GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints
// in the configuration. It iterates through each endpoint, checks if it is enabled,
// and then collects unique labels from the endpoint's labels map.
func (config *Config) GetUniqueExtraMetricLabels() []string {
labels := make([]string, 0)
for _, ep := range config.Endpoints {
if !ep.IsEnabled() {
continue
}
for label := range ep.ExtraLabels {
if contains(labels, label) {
continue
}
labels = append(labels, label)
}
}
return labels
}

func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
for i := 0; i < len(config.Endpoints); i++ {
ep := config.Endpoints[i]
Expand Down
125 changes: 118 additions & 7 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ endpoints:
name: "dir-with-two-config-files",
configPath: dir,
pathAndFiles: map[string]string{
"config.yaml": `endpoints:
"config.yaml": `endpoints:
- name: one
url: https://example.com
conditions:
Expand All @@ -135,7 +135,7 @@ endpoints:
url: https://example.org
conditions:
- "len([BODY]) > 0"`,
"config.yml": `endpoints:
"config.yml": `endpoints:
- name: three
url: https://twin.sh/health
conditions:
Expand Down Expand Up @@ -237,7 +237,7 @@ endpoints:
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
for path, content := range scenario.pathAndFiles {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil {
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
}
}
Expand Down Expand Up @@ -282,7 +282,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`), 0644)
`), 0o644)

t.Run("config-file-as-config-path", func(t *testing.T) {
config, err := LoadConfiguration(configFilePath)
Expand All @@ -298,7 +298,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0644); err != nil {
- "[STATUS] == 200"`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
Expand All @@ -315,7 +315,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0644); err != nil {
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
Expand Down Expand Up @@ -713,7 +713,7 @@ func TestParseAndValidateBadConfigBytes(t *testing.T) {
_, err := parseAndValidateConfigBytes([]byte(`
badconfig:
- asdsa: w0w
usadasdrl: asdxzczxc
usadasdrl: asdxzczxc
asdas:
- soup
`))
Expand Down Expand Up @@ -1943,3 +1943,114 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
})
}
}

func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
tests := []struct {
name string
config *Config
expected []string
}{
{
name: "no-endpoints",
config: &Config{
Endpoints: []*endpoint.Endpoint{},
},
expected: []string{},
},
{
name: "single-endpoint-no-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
},
},
},
expected: []string{},
},
{
name: "single-endpoint-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
},
},
expected: []string{"env", "team"},
},
{
name: "multiple-endpoints-with-labels",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
"module": "auth",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "staging",
"team": "frontend",
},
},
},
},
expected: []string{"env", "team", "module"},
},
{
name: "multiple-endpoints-with-some-disabled",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "endpoint1",
URL: "https://example.com",
Enabled: toPtr(true),
ExtraLabels: map[string]string{
"env": "production",
"team": "backend",
},
},
{
Name: "endpoint2",
URL: "https://example.org",
Enabled: toPtr(false),
ExtraLabels: map[string]string{
"module": "auth",
},
},
},
},
expected: []string{"env", "team"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
labels := tt.config.GetUniqueExtraMetricLabels()
if len(labels) != len(tt.expected) {
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
}
for _, label := range tt.expected {
if !contains(labels, label) {
t.Errorf("expected label %s to be present", label)
}
}
})
}
}
6 changes: 4 additions & 2 deletions config/endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ type Endpoint struct {
// Headers of the request
Headers map[string]string `yaml:"headers,omitempty"`

// ExtraLabels are key-value pairs that can be used to metric the endpoint
ExtraLabels map[string]string `yaml:"extra-labels,omitempty"`

// Interval is the duration to wait between every status check
Interval time.Duration `yaml:"interval,omitempty"`

Expand Down Expand Up @@ -417,8 +420,7 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
result.Connected, result.HTTPStatus, err =
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
Expand Down
16 changes: 16 additions & 0 deletions config/util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package config

// toPtr returns a pointer to the given value
func toPtr[T any](value T) *T {
return &value
}

// contains checks if a key exists in the slice
func contains[T comparable](slice []T, key T) bool {
for _, item := range slice {
if item == key {
return true
}
}
return false
}
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/controller"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
Expand Down Expand Up @@ -49,6 +50,7 @@ func main() {

func start(cfg *config.Config) {
go controller.Handle(cfg)
metrics.InitializePrometheusMetrics(cfg, nil)
watchdog.Monitor(cfg)
go listenToConfigurationFileChanges(cfg)
}
Expand Down
Loading
Loading