Add OpenTelemetry metrics instrumentation (#290)
## Motivation and Context - It is important to have basic observability in place so that availability issues can be figured out easily ## How Has This Been Tested? - Registration of metrics is tested - Prometheus endpoint `/metrics` is included in test cases to test the metrics exposed - Test locally to understand any regression. ## Breaking Changes - No ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context - Telemetry implementation is currently done by keeping metrics in mind as of now, other types of telemetry can be added on top of it later. - Instrumentation takes care of these problems already: - Cardinality control by limiting latency buckets and not including labels that grows with throughput. - Latency buckets are added as per SLA guidelines - Each metric prefixed with `mcp` to easily identify. - Pulling mechanism is used since the idea is to scrape metrics via an agent like vmagent, prometheus agent or otel-collector. - Metrics follow standard approach of defining counters, histogram and gauges as per opentelemetry guidelines.
This commit is contained in:
parent
12ab632d32
commit
d237fc90f8
3 changed files with 538 additions and 21 deletions
|
|
@ -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...))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
91
internal/api/handlers/v0/telemetry_test.go
Normal file
91
internal/api/handlers/v0/telemetry_test.go
Normal file
|
|
@ -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}\"")
|
||||
}
|
||||
Loading…
Reference in a new issue