Repository: i-love-flamingo/flamingo
Pull request #503: feat(healthcheck): add measured status healthcheck interface
Author: login: IvanMaidurov
Created: 2025-05-14T15:58:36Z | Merged: 2025-05-19T07:48:03Z



Diff:
diff --git a/core/healthcheck/domain/healthcheck/status.go b/core/healthcheck/domain/healthcheck/status.go
index 0d2e58aa9..14b72eb47 100644
--- a/core/healthcheck/domain/healthcheck/status.go
+++ b/core/healthcheck/domain/healthcheck/status.go
@@ -1,6 +1,14 @@
 package healthcheck
 
-// Status check interface
-type Status interface {
-	Status() (alive bool, details string)
-}
+type (
+	// Status check interface
+	Status interface {
+		Status() (alive bool, details string)
+	}
+
+	// MeasuredStatus healthcheck interface which will be used in metrics gathering
+	MeasuredStatus interface {
+		Status
+		Name() string
+	}
+)
diff --git a/core/healthcheck/interfaces/controllers/healthcheck.go b/core/healthcheck/interfaces/controllers/healthcheck.go
index ab1f8b986..27dc572b1 100644
--- a/core/healthcheck/interfaces/controllers/healthcheck.go
+++ b/core/healthcheck/interfaces/controllers/healthcheck.go
@@ -1,10 +1,16 @@
 package controllers
 
 import (
+	"context"
 	"encoding/json"
 	"net/http"
 
+	"go.opencensus.io/stats"
+	"go.opencensus.io/stats/view"
+	"go.opencensus.io/tag"
+
 	"flamingo.me/flamingo/v3/core/healthcheck/domain/healthcheck"
+	"flamingo.me/flamingo/v3/framework/opencensus"
 )
 
 type (
@@ -29,19 +35,46 @@ type (
 	}
 )
 
+const (
+	statusMeasureChecksName   = "flamingo/status/checks/total"
+	statusMeasureFailuresName = "flamingo/status/failures/total"
+)
+
+var (
+	// healthcheckStatusFailureMeasure counts failures of status checks
+	healthcheckStatusChecksMeasure  = stats.Int64(statusMeasureChecksName, "Count of status checks.", stats.UnitDimensionless)
+	healthcheckStatusFailureMeasure = stats.Int64(statusMeasureFailuresName, "Count of status check failures.", stats.UnitDimensionless)
+
+	name, _ = tag.NewKey("name")
+)
+
+func init() {
+	if err := opencensus.View(statusMeasureFailuresName, healthcheckStatusFailureMeasure, view.Count(), name); err != nil {
+		panic(err)
+	}
+
+	if err := opencensus.View(statusMeasureChecksName, healthcheckStatusChecksMeasure, view.Count(), name); err != nil {
+		panic(err)
+	}
+}
+
 // Inject Healthcheck dependencies
 func (h *Healthcheck) Inject(provider statusProvider) {
 	h.statusProvider = provider
 }
 
 // ServeHTTP responds to healthcheck requests
-func (h *Healthcheck) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
+func (h *Healthcheck) ServeHTTP(responseWriter http.ResponseWriter, req *http.Request) {
 	var resp response
 	var allAlive = true
 
 	for name, status := range h.statusProvider() {
+		increaseMeasureIfMeasuredStatus(req.Context(), status, healthcheckStatusChecksMeasure)
+
 		alive, details := status.Status()
 		if !alive {
+			increaseMeasureIfMeasuredStatus(req.Context(), status, healthcheckStatusFailureMeasure)
+
 			allAlive = false
 		}
 
@@ -58,12 +91,12 @@ func (h *Healthcheck) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
 	}
 
 	respBody, err := json.Marshal(resp)
-	handleErr(err, w)
+	handleErr(err, responseWriter)
 
-	w.Header().Add("Content-Type", "application/json; charset=utf-8")
-	w.WriteHeader(status)
-	_, err = w.Write(respBody)
-	handleErr(err, w)
+	responseWriter.Header().Add("Content-Type", "application/json; charset=utf-8")
+	responseWriter.WriteHeader(status)
+	_, err = responseWriter.Write(respBody)
+	handleErr(err, responseWriter)
 }
 
 // ServeHTTP responds to Ping requests
@@ -90,3 +123,11 @@ func handleErr(err error, w http.ResponseWriter) {
 		_, _ = w.Write([]byte(err.Error()))
 	}
 }
+
+func increaseMeasureIfMeasuredStatus(ctx context.Context, status healthcheck.Status, measure *stats.Int64Measure) {
+	if measuredStatus, ok := status.(healthcheck.MeasuredStatus); ok {
+		recordContext, _ := tag.New(ctx, tag.Upsert(name, measuredStatus.Name()))
+
+		stats.Record(recordContext, measure.M(1))
+	}
+}
diff --git a/core/healthcheck/interfaces/controllers/healthcheck_test.go b/core/healthcheck/interfaces/controllers/healthcheck_test.go
index 24d652f8a..ec18a8db2 100644
--- a/core/healthcheck/interfaces/controllers/healthcheck_test.go
+++ b/core/healthcheck/interfaces/controllers/healthcheck_test.go
@@ -1,13 +1,15 @@
 package controllers
 
 import (
+	"context"
 	"io"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 
-	"flamingo.me/flamingo/v3/core/healthcheck/domain/healthcheck"
 	"github.com/stretchr/testify/assert"
+
+	"flamingo.me/flamingo/v3/core/healthcheck/domain/healthcheck"
 )
 
 type (
@@ -44,7 +46,7 @@ func TestController_Healthcheck(t *testing.T) {
 				},
 			},
 			args: args{
-				request: nil,
+				request: httptest.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil),
 			},
 			want: "{\"services\":[{\"name\":\"test\",\"alive\":true,\"details\":\"alive\"}]}",
 		},
@@ -58,7 +60,7 @@ func TestController_Healthcheck(t *testing.T) {
 				},
 			},
 			args: args{
-				request: nil,
+				request: httptest.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost", nil),
 			},
 			want: "{\"services\":[{\"name\":\"test\",\"alive\":false,\"details\":\"not alive\"}]}",
 		},


Reviews:
user: login: tessig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T12:03:05Z
user: login: IvanMaidurov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:50:57Z
user: login: IvanMaidurov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:51:08Z
user: login: tessig
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T07:40:07Z
user: login: kgrigorev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T07:40:42Z

Review comments:
diff_hunk: @@ -29,19 +35,46 @@ type (
 	}
 )
 
+const (
+	statusMeasureChecksName   = "flamingo/status/checks"
+	statusMeasureFailuresName = "flamingo/status/failures"
path: core/healthcheck/interfaces/controllers/healthcheck.go
user: login: tessig
body: should we use something ending in total? see https://prometheus.io/docs/practices/naming/#metric-and-label-naming
created_at: 2025-05-15T12:00:32Z
author_association: MEMBER
diff_hunk: @@ -1,6 +1,13 @@
 package healthcheck
 
-// Status check interface
-type Status interface {
-	Status() (alive bool, details string)
-}
+type (
+	// Status check interface
+	Status interface {
+		Status() (alive bool, details string)
+	}
+
+	MeasuredStatus interface {
+		Status
+		ServiceName() string
path: core/healthcheck/domain/healthcheck/status.go
user: login: tessig
body: A status is not necessarily connected to a service. I would just go for `Name`
created_at: 2025-05-15T12:02:14Z
author_association: MEMBER
diff_hunk: @@ -29,19 +35,46 @@ type (
 	}
 )
 
+const (
+	statusMeasureChecksName   = "flamingo/status/checks"
+	statusMeasureFailuresName = "flamingo/status/failures"
path: core/healthcheck/interfaces/controllers/healthcheck.go
user: login: IvanMaidurov
body: done
created_at: 2025-05-15T14:50:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,13 @@
 package healthcheck
 
-// Status check interface
-type Status interface {
-	Status() (alive bool, details string)
-}
+type (
+	// Status check interface
+	Status interface {
+		Status() (alive bool, details string)
+	}
+
+	MeasuredStatus interface {
+		Status
+		ServiceName() string
path: core/healthcheck/domain/healthcheck/status.go
user: login: IvanMaidurov
body: done
created_at: 2025-05-15T14:51:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,13 @@
 package healthcheck
 
-// Status check interface
-type Status interface {
-	Status() (alive bool, details string)
-}
+type (
+	// Status check interface
+	Status interface {
+		Status() (alive bool, details string)
+	}
+
+	MeasuredStatus interface {
path: core/healthcheck/domain/healthcheck/status.go
user: login: kgrigorev
body: since it is a public API it may be worth adding a comment with the description why this interface is needed or how it can be used
created_at: 2025-05-19T07:40:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-19T07:47:36Z
body: ![Code Coverage](https://img.shields.io/badge/Code%20Coverage-43%25-critical?style=flat)

Package | Line Rate | Health
-------- | --------- | ------
flamingo.me/flamingo/v3 | 44% | ❌
flamingo.me/flamingo/v3/core/auth | 13% | ❌
flamingo.me/flamingo/v3/core/auth/example/custom | 0% | ❌
flamingo.me/flamingo/v3/core/auth/example | 0% | ❌
flamingo.me/flamingo/v3/core/auth/fake | 50% | ➖
flamingo.me/flamingo/v3/core/auth/http | 40% | ❌
flamingo.me/flamingo/v3/core/auth/mock | 0% | ❌
flamingo.me/flamingo/v3/core/auth/oauth | 49% | ❌
flamingo.me/flamingo/v3/core/cache | 66% | ➖
flamingo.me/flamingo/v3/core/gotemplate | 87% | ✔
flamingo.me/flamingo/v3/core/healthcheck/domain/healthcheck | 0% | ❌
flamingo.me/flamingo/v3/core/healthcheck/interfaces/controllers | 67% | ➖
flamingo.me/flamingo/v3/core/healthcheck | 66% | ➖
flamingo.me/flamingo/v3/core/internalauth/application | 88% | ✔
flamingo.me/flamingo/v3/core/internalauth | 100% | ✔
flamingo.me/flamingo/v3/core/locale/application | 87% | ✔
flamingo.me/flamingo/v3/core/locale/domain | 75% | ✔
flamingo.me/flamingo/v3/core/locale/infrastructure/fake | 0% | ❌
flamingo.me/flamingo/v3/core/locale/infrastructure | 0% | ❌
flamingo.me/flamingo/v3/core/locale/interfaces/controllers | 100% | ✔
flamingo.me/flamingo/v3/core/locale/interfaces/templatefunctions | 92% | ✔
flamingo.me/flamingo/v3/core/locale | 59% | ➖
flamingo.me/flamingo/v3/core/oauth/application | 4% | ❌
flamingo.me/flamingo/v3/core/oauth/application/fake | 0% | ❌
flamingo.me/flamingo/v3/core/oauth/domain | 62% | ➖
flamingo.me/flamingo/v3/core/oauth/example | 0% | ❌
flamingo.me/flamingo/v3/core/oauth/infrastructure | 0% | ❌
flamingo.me/flamingo/v3/core/oauth/interfaces | 0% | ❌
flamingo.me/flamingo/v3/core/oauth/interfaces/fake | 0% | ❌
flamingo.me/flamingo/v3/core/oauth | 55% | ➖
flamingo.me/flamingo/v3/core/requestlogger | 100% | ✔
flamingo.me/flamingo/v3/core/requesttask | 10% | ❌
flamingo.me/flamingo/v3/core/robotstxt/interfaces | 0% | ❌
flamingo.me/flamingo/v3/core/robotstxt | 50% | ➖
flamingo.me/flamingo/v3/core/runtime | 77% | ✔
flamingo.me/flamingo/v3/core/security/application/role | 95% | ✔
flamingo.me/flamingo/v3/core/security/application | 98% | ✔
flamingo.me/flamingo/v3/core/security/application/voter | 100% | ✔
flamingo.me/flamingo/v3/core/security/domain | 0% | ❌
flamingo.me/flamingo/v3/core/security/interface/controller | 100% | ✔
flamingo.me/flamingo/v3/core/security/interface/middleware | 72% | ➖
flamingo.me/flamingo/v3/core/security | 59% | ➖
flamingo.me/flamingo/v3/core/silentzap | 0% | ❌
flamingo.me/flamingo/v3/core/zap | 78% | ✔
flamingo.me/flamingo/v3/examples/hello-world | 0% | ❌
flamingo.me/flamingo/v3/framework/cmd | 5% | ❌
flamingo.me/flamingo/v3/framework/config | 49% | ❌
flamingo.me/flamingo/v3/framework/controller | 0% | ❌
flamingo.me/flamingo/v3/framework/flamingo | 36% | ❌
flamingo.me/flamingo/v3/framework/flamingo/healthcheck | 0% | ❌
flamingo.me/flamingo/v3/framework | 29% | ❌
flamingo.me/flamingo/v3/framework/opencensus | 78% | ✔
flamingo.me/flamingo/v3/framework/prefixrouter | 46% | ❌
flamingo.me/flamingo/v3/framework/systemendpoint/application | 0% | ❌
flamingo.me/flamingo/v3/framework/systemendpoint | 27% | ❌
flamingo.me/flamingo/v3/framework/testutil | 0% | ❌
flamingo.me/flamingo/v3/framework/web | 48% | ❌
flamingo.me/flamingo/v3/framework/web/filter | 0% | ❌
**Summary** | **43%** (3691 / 8635) | ❌

<!-- Sticky Pull Request Comment -->
author_association: CONTRIBUTOR