Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ go test -tags=integration ./...

- `GET /health/live` reports process liveness without checking PostgreSQL.
- `GET /health/ready` reports database readiness with a bounded PostgreSQL ping.
- `GET /metrics` exposes Prometheus metrics only when `ENABLE_METRICS=true`.
- `GET /metrics` exposes Prometheus metrics only when `ENABLE_METRICS=true`; it is an operational endpoint and is intentionally excluded from Swagger.
- Security audit events are emitted as structured JSON logs with request IDs and safe categorical context.

Metrics labels use route templates and avoid user-controlled values. Restrict `/metrics` to trusted monitoring systems in production. Grafana, Loki, and tracing infrastructure are intentionally not bundled.
Expand Down
57 changes: 57 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,54 @@ const docTemplate = `{
}
}
}
},
"/health/live": {
"get": {
"description": "Returns liveness without checking external dependencies.",
"produces": [
"application/json"
],
"tags": [
"operations"
],
"summary": "Check process liveness",
"operationId": "healthLive",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse"
}
}
}
}
},
"/health/ready": {
"get": {
"description": "Checks PostgreSQL connectivity with a bounded timeout.",
"produces": [
"application/json"
],
"tags": [
"operations"
],
"summary": "Check service readiness",
"operationId": "healthReady",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse"
}
}
}
}
}
},
"definitions": {
Expand Down Expand Up @@ -874,6 +922,15 @@ const docTemplate = `{
}
}
},
"internal_adapter_inbound_http.HealthResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
}
}
},
"internal_adapter_inbound_http.MessageResponse": {
"type": "object",
"properties": {
Expand Down
57 changes: 57 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,54 @@
}
}
}
},
"/health/live": {
"get": {
"description": "Returns liveness without checking external dependencies.",
"produces": [
"application/json"
],
"tags": [
"operations"
],
"summary": "Check process liveness",
"operationId": "healthLive",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse"
}
}
}
}
},
"/health/ready": {
"get": {
"description": "Checks PostgreSQL connectivity with a bounded timeout.",
"produces": [
"application/json"
],
"tags": [
"operations"
],
"summary": "Check service readiness",
"operationId": "healthReady",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse"
}
},
"503": {
"description": "Service Unavailable",
"schema": {
"$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse"
}
}
}
}
}
},
"definitions": {
Expand Down Expand Up @@ -872,6 +920,15 @@
}
}
},
"internal_adapter_inbound_http.HealthResponse": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
}
}
},
"internal_adapter_inbound_http.MessageResponse": {
"type": "object",
"properties": {
Expand Down
38 changes: 38 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ definitions:
example: invalid input
type: string
type: object
internal_adapter_inbound_http.HealthResponse:
properties:
status:
example: ok
type: string
type: object
internal_adapter_inbound_http.MessageResponse:
properties:
message:
Expand Down Expand Up @@ -591,6 +597,38 @@ paths:
summary: Update current user
tags:
- users
/health/live:
get:
description: Returns liveness without checking external dependencies.
operationId: healthLive
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_adapter_inbound_http.HealthResponse'
summary: Check process liveness
tags:
- operations
/health/ready:
get:
description: Checks PostgreSQL connectivity with a bounded timeout.
operationId: healthReady
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/internal_adapter_inbound_http.HealthResponse'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/internal_adapter_inbound_http.HealthResponse'
summary: Check service readiness
tags:
- operations
schemes:
- http
- https
Expand Down
17 changes: 17 additions & 0 deletions internal/adapter/inbound/http/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,27 @@ func NewHealthHandler(db DBPinger) *HealthHandler {
return &HealthHandler{db: db}
}

// Live godoc
// @Summary Check process liveness
// @ID healthLive
// @Description Returns liveness without checking external dependencies.
// @Tags operations
// @Produce json
// @Success 200 {object} HealthResponse
// @Router /health/live [get]
func (h *HealthHandler) Live(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

// Ready godoc
// @Summary Check service readiness
// @ID healthReady
// @Description Checks PostgreSQL connectivity with a bounded timeout.
// @Tags operations
// @Produce json
// @Success 200 {object} HealthResponse
// @Failure 503 {object} HealthResponse
// @Router /health/ready [get]
func (h *HealthHandler) Ready(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"})
Expand Down
13 changes: 13 additions & 0 deletions internal/adapter/inbound/http/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package http

import (
"github.com/Jonathan0823/auth-go/internal/platform"
"github.com/gin-gonic/gin"
)

func RegisterMetricsRoute(r *gin.Engine, metrics *platform.Metrics, enabled bool) {
if !enabled || metrics == nil {
return
}
r.GET("/metrics", gin.WrapH(metrics.Handler()))
}
84 changes: 84 additions & 0 deletions internal/adapter/inbound/http/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package http

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

inhttpmw "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware"
"github.com/Jonathan0823/auth-go/internal/platform"
"github.com/gin-gonic/gin"
)

func TestMetricsMiddlewareUsesRouteTemplates(t *testing.T) {
gin.SetMode(gin.TestMode)
metrics := platform.NewMetrics(nil)
router := gin.New()
router.Use(inhttpmw.Metrics(metrics))
router.GET("/users/:id", func(c *gin.Context) {})

for _, id := range []string{"1", "2"} {
request := httptest.NewRequest(http.MethodGet, "/users/"+id, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
}

families, err := metrics.Registry.Gather()
if err != nil {
t.Fatal(err)
}
var requestCount float64
for _, family := range families {
if family.GetName() != "auth_go_http_requests_total" {
continue
}
for _, metric := range family.GetMetric() {
labels := make(map[string]string)
for _, label := range metric.GetLabel() {
labels[label.GetName()] = label.GetValue()
}
if labels["route"] != "/users/:id" {
t.Fatalf("route label = %q, want /users/:id", labels["route"])
}
requestCount += metric.GetCounter().GetValue()
}
}
if requestCount != 2 {
t.Fatalf("request count = %v, want 2", requestCount)
}
}

func TestMetricsRouteIsOptIn(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, enabled := range []bool{false, true} {
t.Run(map[bool]string{false: "disabled", true: "enabled"}[enabled], func(t *testing.T) {
metrics := platform.NewMetrics(nil)
router := gin.New()
if enabled {
router.Use(inhttpmw.Metrics(metrics))
router.GET("/ping", func(c *gin.Context) {})
}
RegisterMetricsRoute(router, metrics, enabled)
if enabled {
pingRequest := httptest.NewRequest(http.MethodGet, "/ping", nil)
pingResponse := httptest.NewRecorder()
router.ServeHTTP(pingResponse, pingRequest)
}
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)

wantStatus := http.StatusNotFound
if enabled {
wantStatus = http.StatusOK
}
if response.Code != wantStatus {
t.Fatalf("status = %d, want %d", response.Code, wantStatus)
}
if enabled && !strings.Contains(response.Body.String(), "auth_go_http_requests_total") {
t.Fatal("metrics response does not contain HTTP request metric")
}
})
}
}
33 changes: 33 additions & 0 deletions internal/adapter/inbound/http/middleware/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package middleware

import (
"strconv"
"time"

"github.com/Jonathan0823/auth-go/internal/platform"
"github.com/gin-gonic/gin"
)

func Metrics(metrics *platform.Metrics) gin.HandlerFunc {
if metrics == nil {
return func(c *gin.Context) { c.Next() }
}

return func(c *gin.Context) {
metrics.IncInFlight()
started := time.Now()
c.Next()

route := c.FullPath()
if route == "" {
route = "/__unmatched__"
}
metrics.ObserveHTTPRequest(
c.Request.Method,
route,
strconv.Itoa(c.Writer.Status()),
time.Since(started),
)
metrics.DecInFlight()
}
}
Loading
Loading