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:
pree-dew 2025-08-21 18:53:37 +05:30 • committed by GitHub
commit d237fc90f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 538 additions and 21 deletions

View file

@ -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...))
}

View file

@ -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)

View 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}\"")
}

View file

@ -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) {

View file

@ -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)

View file

@ -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,

View file

@ -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()
}

View file

@ -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)
}
})
}
}