diff --git a/cmd/registry/main.go b/cmd/registry/main.go index 8b4c263..0c58607 100644 --- a/cmd/registry/main.go +++ b/cmd/registry/main.go @@ -16,6 +16,7 @@ import ( "github.com/modelcontextprotocol/registry/internal/database" "github.com/modelcontextprotocol/registry/internal/model" "github.com/modelcontextprotocol/registry/internal/service" + "github.com/modelcontextprotocol/registry/internal/telemetry" ) func main() { @@ -91,8 +92,20 @@ func main() { } } + shutdownTelemetry, metrics, err := telemetry.InitMetrics(cfg.Version) + if err != nil { + log.Printf("Failed to initialize metrics: %v", err) + return + } + + defer func() { + if err := shutdownTelemetry(context.Background()); err != nil { + log.Printf("Failed to shutdown telemetry: %v", err) + } + }() + // Initialize HTTP server - server := api.NewServer(cfg, registryService) + server := api.NewServer(cfg, registryService, metrics) // Start server in a goroutine so it doesn't block signal handling go func() { diff --git a/go.mod b/go.mod index 03c16b6..914da47 100644 --- a/go.mod +++ b/go.mod @@ -5,29 +5,47 @@ go 1.24.0 require ( github.com/caarlos0/env/v11 v11.3.1 github.com/danielgtaylor/huma/v2 v2.34.1 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/prometheus/client_golang v1.23.0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/stretchr/testify v1.10.0 go.mongodb.org/mongo-driver v1.17.3 + go.opentelemetry.io/contrib/instrumentation/runtime v0.62.0 + go.opentelemetry.io/otel v1.37.0 + go.opentelemetry.io/otel/exporters/prometheus v0.59.1 + go.opentelemetry.io/otel/metric v1.37.0 + go.opentelemetry.io/otel/sdk v1.37.0 + go.opentelemetry.io/otel/sdk/metric v1.37.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5f588fb..87cc59b 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,18 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= @@ -13,21 +21,34 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= +github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f h1:QQB6SuvGZjK8kdc2YaLJpYhV8fxauOsjE6jgcL6YJ8Q= +github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f/go.mod h1:P8AwMgdD7XEr6QRUJ2QWLpiAZTgTE2UYgjlu3svompI= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -45,6 +66,24 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/runtime v0.62.0 h1:ZIt0ya9/y4WyRIzfLC8hQRRsWg0J9M9GyaGtIMiElZI= +go.opentelemetry.io/contrib/instrumentation/runtime v0.62.0/go.mod h1:F1aJ9VuiKWOlWwKdTYDUp1aoS0HzQxg38/VLxKmhm5U= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/prometheus v0.59.1 h1:HcpSkTkJbggT8bjYP+BjyqPWlD17BH9C5CYNKeDzmcA= +go.opentelemetry.io/otel/exporters/prometheus v0.59.1/go.mod h1:0FJL+gjuUoM07xzik3KPBaN+nz/CoB15kV6WLMiXZag= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= @@ -62,6 +101,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -74,6 +115,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/api/handlers/v0/health.go b/internal/api/handlers/v0/health.go index 3cef98d..ea2060a 100644 --- a/internal/api/handlers/v0/health.go +++ b/internal/api/handlers/v0/health.go @@ -5,7 +5,11 @@ import ( "net/http" "github.com/danielgtaylor/huma/v2" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/telemetry" ) // HealthBody represents the health check response body @@ -15,7 +19,7 @@ type HealthBody struct { } // RegisterHealthEndpoint registers the health check endpoint -func RegisterHealthEndpoint(api huma.API, cfg *config.Config) { +func RegisterHealthEndpoint(api huma.API, cfg *config.Config, metrics *telemetry.Metrics) { huma.Register(api, huma.Operation{ OperationID: "get-health", Method: http.MethodGet, @@ -23,7 +27,10 @@ func RegisterHealthEndpoint(api huma.API, cfg *config.Config) { Summary: "Health check", Description: "Check the health status of the API", Tags: []string{"health"}, - }, func(_ context.Context, _ *struct{}) (*Response[HealthBody], error) { + }, func(ctx context.Context, _ *struct{}) (*Response[HealthBody], error) { + // Record the health check metrics + recordHealthMetrics(ctx, metrics, "/v0/health", cfg.Version) + return &Response[HealthBody]{ Body: HealthBody{ Status: "ok", @@ -32,3 +39,15 @@ func RegisterHealthEndpoint(api huma.API, cfg *config.Config) { }, nil }) } + +// recordHealthMetrics records the health check metrics +func recordHealthMetrics(ctx context.Context, metrics *telemetry.Metrics, path string, version string) { + attrs := []attribute.KeyValue{ + attribute.String("path", path), + attribute.String("version", version), + attribute.String("service", telemetry.Namespace), + } + + // metric : Up status (1 = healthy, 0 = unhealthy) + metrics.Up.Record(ctx, 1, metric.WithAttributes(attrs...)) +} diff --git a/internal/api/handlers/v0/health_test.go b/internal/api/handlers/v0/health_test.go index 79531dd..1de394e 100644 --- a/internal/api/handlers/v0/health_test.go +++ b/internal/api/handlers/v0/health_test.go @@ -1,15 +1,18 @@ package v0_test import ( + "context" "net/http" "net/http/httptest" "testing" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" + "github.com/stretchr/testify/assert" + v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" "github.com/modelcontextprotocol/registry/internal/config" - "github.com/stretchr/testify/assert" + "github.com/modelcontextprotocol/registry/internal/telemetry" ) func TestHealthEndpoint(t *testing.T) { @@ -50,8 +53,10 @@ func TestHealthEndpoint(t *testing.T) { mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) + shutdownTelemetry, metrics, _ := telemetry.InitMetrics("test") + // Register the health endpoint - v0.RegisterHealthEndpoint(api, tc.config) + v0.RegisterHealthEndpoint(api, tc.config, metrics) // Create a test request req := httptest.NewRequest(http.MethodGet, "/v0/health", nil) @@ -60,6 +65,9 @@ func TestHealthEndpoint(t *testing.T) { // Serve the request mux.ServeHTTP(w, req) + // shut down the metric provider + _ = shutdownTelemetry(context.Background()) + // Check the status code assert.Equal(t, tc.expectedStatus, w.Code) diff --git a/internal/api/handlers/v0/telemetry_test.go b/internal/api/handlers/v0/telemetry_test.go new file mode 100644 index 0000000..6e257fe --- /dev/null +++ b/internal/api/handlers/v0/telemetry_test.go @@ -0,0 +1,91 @@ +package v0_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humago" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" + "github.com/modelcontextprotocol/registry/internal/api/router" + "github.com/modelcontextprotocol/registry/internal/config" + "github.com/modelcontextprotocol/registry/internal/model" + "github.com/modelcontextprotocol/registry/internal/telemetry" +) + +func mockServerEndpoint(registry *MockRegistryService, serverID string) { + serverDetail := &model.ServerDetail{ + Server: model.Server{ + ID: serverID, + Name: "test-server-detail", + Description: "Test server detail", + Repository: model.Repository{ + URL: "https://github.com/example/test-server-detail", + Source: "github", + ID: "example/test-server-detail", + }, + VersionDetail: model.VersionDetail{ + Version: "2.0.0", + ReleaseDate: "2025-05-27T12:00:00Z", + IsLatest: true, + }, + }, + } + registry.Mock.On("GetByID", serverID).Return(serverDetail, nil) +} + +func TestPrometheusHandler(t *testing.T) { + mockRegistry := new(MockRegistryService) + + serverID := uuid.New().String() + mockServerEndpoint(mockRegistry, serverID) + + cfg := config.NewConfig() + shutdownTelemetry, metrics, _ := telemetry.InitMetrics("dev") + + mux := http.NewServeMux() + api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) + + // Add metrics middleware with options + api.UseMiddleware(router.MetricTelemetryMiddleware(metrics, + router.WithSkipPaths("/health", "/metrics", "/ping", "/docs"), + )) + v0.RegisterHealthEndpoint(api, cfg, metrics) + v0.RegisterServersEndpoints(api, mockRegistry) + + // Add /metrics for Prometheus metrics using promhttp + mux.Handle("/metrics", metrics.PrometheusHandler()) + + // Create request + url := "/v0/servers/" + serverID + req := httptest.NewRequest(http.MethodGet, url, nil) + w := httptest.NewRecorder() + + // Serve the request + mux.ServeHTTP(w, req) + + // Check the status code + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/metrics", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + + // shutdown metrics provider + _ = shutdownTelemetry(context.Background()) + + assert.Equal(t, http.StatusOK, w.Code, "Expected status OK for /metrics endpoint") + + body := w.Body.String() + // Check if the response body contains expected metrics + assert.Contains(t, body, "mcp_registry_http_request_duration_bucket") + assert.Contains(t, body, "mcp_registry_http_requests_total") + assert.Contains(t, body, "path=\"/v0/servers/{id}\"") +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 18a2012..cb18a69 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -3,17 +3,98 @@ package router import ( "net/http" + "strings" + "time" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/service" + "github.com/modelcontextprotocol/registry/internal/telemetry" ) +// Middleware configuration options +type middlewareConfig struct { + skipPaths map[string]bool +} + +type MiddlewareOption func(*middlewareConfig) + +// getRoutePath extracts the route pattern from the context +func getRoutePath(ctx huma.Context) string { + // Try to get the operation from context + if op := ctx.Operation().Path; op != "" { + return ctx.Operation().Path + } + + // Fallback to URL path (less ideal for metrics as it includes path parameters) + return ctx.URL().Path +} + +func MetricTelemetryMiddleware(metrics *telemetry.Metrics, options ...MiddlewareOption) func(huma.Context, func(huma.Context)) { + config := &middlewareConfig{ + skipPaths: make(map[string]bool), + } + + for _, opt := range options { + opt(config) + } + + return func(ctx huma.Context, next func(huma.Context)) { + path := ctx.URL().Path + + // Skip instrumentation for specified paths + // extract the last part of the path to match against skipPaths + pathParts := strings.Split(path, "/") + pathToMatch := "/" + pathParts[len(pathParts)-1] + if config.skipPaths[pathToMatch] || config.skipPaths[path] { + next(ctx) + return + } + + start := time.Now() + method := ctx.Method() + routePath := getRoutePath(ctx) + + next(ctx) + + duration := time.Since(start).Seconds() + statusCode := ctx.Status() + + // Combine common and custom attributes + attrs := []attribute.KeyValue{ + attribute.String("method", method), + attribute.String("path", routePath), + attribute.Int("status_code", statusCode), + } + + // Record metrics + metrics.Requests.Add(ctx.Context(), 1, metric.WithAttributes(attrs...)) + + if statusCode >= 400 { + metrics.ErrorCount.Add(ctx.Context(), 1, metric.WithAttributes(attrs...)) + } + + metrics.RequestDuration.Record(ctx.Context(), duration, metric.WithAttributes(attrs...)) + } +} + +// WithSkipPaths allows skipping instrumentation for specific paths +func WithSkipPaths(paths ...string) MiddlewareOption { + return func(c *middlewareConfig) { + for _, path := range paths { + c.skipPaths[path] = true + } + } +} + // NewHumaAPI creates a new Huma API with all routes registered // //nolint:ireturn // huma.API is the expected interface type for Huma APIs -func NewHumaAPI(cfg *config.Config, registry service.RegistryService, mux *http.ServeMux) huma.API { +func NewHumaAPI(cfg *config.Config, registry service.RegistryService, mux *http.ServeMux, metrics *telemetry.Metrics) huma.API { // Create Huma API configuration humaConfig := huma.DefaultConfig("MCP Registry API", "1.0.0") humaConfig.Info.Description = "A community driven registry service for Model Context Protocol (MCP) servers." @@ -23,8 +104,16 @@ func NewHumaAPI(cfg *config.Config, registry service.RegistryService, mux *http. // Create a new API using humago adapter for standard library api := humago.New(mux, humaConfig) + // Add metrics middleware with options + api.UseMiddleware(MetricTelemetryMiddleware(metrics, + WithSkipPaths("/health", "/metrics", "/ping", "/docs"), + )) + // Register routes for all API versions - RegisterV0Routes(api, cfg, registry) + RegisterV0Routes(api, cfg, registry, metrics) + + // Add /metrics for Prometheus metrics using promhttp + mux.Handle("/metrics", metrics.PrometheusHandler()) // Add redirect from / to /docs mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/router/v0.go b/internal/api/router/v0.go index cfad66d..d35df48 100644 --- a/internal/api/router/v0.go +++ b/internal/api/router/v0.go @@ -3,16 +3,18 @@ package router import ( "github.com/danielgtaylor/huma/v2" + v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0" v0auth "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/service" + "github.com/modelcontextprotocol/registry/internal/telemetry" ) func RegisterV0Routes( - api huma.API, cfg *config.Config, registry service.RegistryService, + api huma.API, cfg *config.Config, registry service.RegistryService, metrics *telemetry.Metrics, ) { - v0.RegisterHealthEndpoint(api, cfg) + v0.RegisterHealthEndpoint(api, cfg, metrics) v0.RegisterPingEndpoint(api) v0.RegisterServersEndpoints(api, registry) v0auth.RegisterAuthEndpoints(api, cfg) diff --git a/internal/api/server.go b/internal/api/server.go index 8cd8fa2..97f145d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,9 +7,11 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/modelcontextprotocol/registry/internal/api/router" "github.com/modelcontextprotocol/registry/internal/config" "github.com/modelcontextprotocol/registry/internal/service" + "github.com/modelcontextprotocol/registry/internal/telemetry" ) // Server represents the HTTP server @@ -21,10 +23,11 @@ type Server struct { } // NewServer creates a new HTTP server -func NewServer(cfg *config.Config, registryService service.RegistryService) *Server { +func NewServer(cfg *config.Config, registryService service.RegistryService, metrics *telemetry.Metrics) *Server { // Create HTTP mux and Huma API mux := http.NewServeMux() - api := router.NewHumaAPI(cfg, registryService, mux) + + api := router.NewHumaAPI(cfg, registryService, mux, metrics) server := &Server{ config: cfg, diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go new file mode 100644 index 0000000..8b24d65 --- /dev/null +++ b/internal/telemetry/metrics.go @@ -0,0 +1,147 @@ +package telemetry + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.17.0" +) + +const ( + Namespace = "mcp_registry" +) + +type Metrics struct { + // Requests tracks the number of HTTP requests + Requests metric.Int64Counter + + // RequestDuration tracks the duration of HTTP Requests + RequestDuration metric.Float64Histogram + + // ErrorCount tracks the number of errors + ErrorCount metric.Int64Counter + + // Up tracks the health of the service + Up metric.Int64Gauge +} + +// ShutdownFunc is a delegate that shuts down the OpenTelemetry components. +type ShutdownFunc func(ctx context.Context) error + +func NewMetrics(meter metric.Meter) (*Metrics, error) { + req, err := meter.Int64Counter( + Namespace+".http.requests", + metric.WithDescription("Total number of HTTP requests"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create request counter: %w", err) + } + + reqDuration, err := meter.Float64Histogram( + Namespace+".http.request.duration", + metric.WithDescription("Duration of HTTP requests in seconds"), + metric.WithExplicitBucketBoundaries( + 0.005, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 20.0, 50.0, + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to create request duration histogram: %w", err) + } + + errCount, err := meter.Int64Counter( + Namespace+".http.errors", + metric.WithDescription("Total number of HTTP errors"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create error counter: %w", err) + } + + up, err := meter.Int64Gauge( + Namespace+".service.up", + metric.WithDescription("Service health status (1 for up, 0 for down)"), + ) + if err != nil { + return nil, fmt.Errorf("failed to create service up gauge: %w", err) + } + + return &Metrics{ + Requests: req, + RequestDuration: reqDuration, + ErrorCount: errCount, + Up: up, + }, nil +} + +func NewPrometheusMeterProvider(res *resource.Resource, exp *prometheus.Exporter) (*sdkmetric.MeterProvider, error) { + if exp == nil { + return nil, errors.New("exporter cannot be nil") + } + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(exp), + ) + + return meterProvider, nil +} + +func InitMetrics(version string) (ShutdownFunc, *Metrics, error) { + // Initialized the returned shutdownFunc to no-op. + shutdown := func(_ context.Context) error { return nil } + + res, err := resource.New(context.Background(), + resource.WithAttributes( + semconv.ServiceName(Namespace), + semconv.ServiceVersion(version), + ), + resource.WithProcessRuntimeDescription(), + ) + if err != nil { + return shutdown, nil, fmt.Errorf("failed to create resource: %w", err) + } + + res, err = resource.Merge(resource.Default(), res) + if err != nil { + return shutdown, nil, fmt.Errorf("failed to merge resources: %w", err) + } + + exporter, err := prometheus.New() + if err != nil { + return shutdown, nil, fmt.Errorf("failed to create Prometheus exporter: %w", err) + } + + mp, err := NewPrometheusMeterProvider(res, exporter) + if err != nil { + return shutdown, nil, fmt.Errorf("failed to create Prometheus meter provider: %w", err) + } + otel.SetMeterProvider(mp) + + // Update the returned shutdownFunc that calls metric provider + // shutdown methods and make sure that a non-nil error is returned + // if any returned an error. + shutdown = func(ctx context.Context) error { + var retErr error + if err := mp.Shutdown(ctx); err != nil { + retErr = err + } + return retErr + } + + meter := mp.Meter(Namespace, metric.WithSchemaURL(semconv.SchemaURL), metric.WithInstrumentationVersion(runtime.Version())) + metrics, err := NewMetrics(meter) + return shutdown, metrics, err +} + +// PrometheusHandler returns the HTTP handler for Prometheus metrics +// This handler serves the metrics endpoint for Prometheus to scrape. +func (m *Metrics) PrometheusHandler() http.Handler { + return promhttp.Handler() +} diff --git a/internal/telemetry/metrics_test.go b/internal/telemetry/metrics_test.go new file mode 100644 index 0000000..30060f1 --- /dev/null +++ b/internal/telemetry/metrics_test.go @@ -0,0 +1,84 @@ +package telemetry_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + + "github.com/modelcontextprotocol/registry/internal/telemetry" +) + +func TestNewMetrics(t *testing.T) { + tests := []struct { + name string + meter metric.Meter + expectedMetric string + expectedError error + }{ + { + name: "Valid Meter", + meter: noop.NewMeterProvider().Meter("test"), + expectedMetric: "http_requests_total", + expectedError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metrics, err := telemetry.NewMetrics(tt.meter) + + // Ensure the metric is registered correctly + assert.NoError(t, err) + assert.NotNil(t, metrics) + assert.NotNil(t, metrics.Requests) + }) + } +} + +func TestNewPrometheusMeterProvider(t *testing.T) { + tests := []struct { + name string + wantErr bool + mockPrometheus func() (*prometheus.Exporter, error) + expectedError error + }{ + { + name: "Successful creation of meter provider", + wantErr: false, + mockPrometheus: func() (*prometheus.Exporter, error) { + return &prometheus.Exporter{ + Reader: sdkmetric.NewManualReader(), + }, nil + }, + }, + { + name: "Error on resource creation", + wantErr: true, + expectedError: errors.New("error creating prometheus resource"), + mockPrometheus: func() (*prometheus.Exporter, error) { + return nil, errors.New("error creating prometheus resource") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := resource.NewSchemaless() // Use an empty resource for testing. + exp, _ := tt.mockPrometheus() + mp, err := telemetry.NewPrometheusMeterProvider(res, exp) + + if tt.wantErr { + assert.NotNil(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, mp) + } + }) + } +}