Skip to content

Commit f0c4439

Browse files
committed
integration test for metrics
Signed-off-by: Jeremy Drouillard <jeremy@stacklok.com>
1 parent 96a2f71 commit f0c4439

4 files changed

Lines changed: 169 additions & 16 deletions

File tree

pkg/vmcp/server/server.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,17 @@ func New(
199199
// This provides SDK-agnostic elicitation with security validation
200200
elicitationHandler := composer.NewDefaultElicitationHandler(sdkElicitationRequester)
201201

202+
// Decorate backend client with telemetry if provider is configured
203+
// This must happen BEFORE creating the workflow engine so that workflow
204+
// backend calls are instrumented when they occur during workflow execution.
205+
if cfg.TelemetryProvider != nil {
206+
var err error
207+
backendClient, err = monitorBackends(context.Background(), cfg.TelemetryProvider.MeterProvider(), backends, backendClient)
208+
if err != nil {
209+
return nil, fmt.Errorf("failed to monitor backends: %w", err)
210+
}
211+
}
212+
202213
// Create workflow engine (composer) for executing composite tools
203214
// The composer orchestrates multi-step workflows across backends
204215
// Use in-memory state store with 5-minute cleanup interval and 1-hour max age for completed workflows
@@ -211,12 +222,8 @@ func New(
211222
return nil, fmt.Errorf("workflow validation failed: %w", err)
212223
}
213224

214-
// Decorate backend client and workflow executors with telemetry if provider is configured
225+
// Decorate workflow executors with telemetry if provider is configured
215226
if cfg.TelemetryProvider != nil {
216-
backendClient, err = monitorBackends(context.Background(), cfg.TelemetryProvider.MeterProvider(), backends, backendClient)
217-
if err != nil {
218-
return nil, fmt.Errorf("failed to monitor backends: %w", err)
219-
}
220227
workflowExecutors, err = monitorWorkflowExecutors(cfg.TelemetryProvider.MeterProvider(), workflowExecutors)
221228
if err != nil {
222229
return nil, fmt.Errorf("failed to monitor workflow executors: %w", err)

pkg/vmcp/server/telemetry.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ func monitorBackends(ctx context.Context, meterProvider metric.MeterProvider, ba
3030
}
3131
backendCount.Record(ctx, int64(len(backends)))
3232

33-
requestsTotal, err := meter.Int64Counter("toolhive_vmcp_requests_total", metric.WithDescription("Total number of requests per backend"))
33+
requestsTotal, err := meter.Int64Counter("toolhive_vmcp_backend_requests_total", metric.WithDescription("Total number of requests per backend"))
3434
if err != nil {
3535
return nil, fmt.Errorf("failed to create requests total counter: %w", err)
3636
}
37-
errorsTotal, err := meter.Int64Counter("toolhive_vmcp_errors_total", metric.WithDescription("Total number of errors per backend"))
37+
errorsTotal, err := meter.Int64Counter("toolhive_vmcp_backend_errors_total", metric.WithDescription("Total number of errors per backend"))
3838
if err != nil {
3939
return nil, fmt.Errorf("failed to create errors total counter: %w", err)
4040
}
41-
requestsDuration, err := meter.Float64Histogram("toolhive_vmcp_requests_duration", metric.WithDescription("Duration of requests in seconds per backend"))
41+
requestsDuration, err := meter.Float64Histogram("toolhive_vmcp_backend_requests_duration", metric.WithDescription("Duration of requests in seconds per backend"))
4242
if err != nil {
4343
return nil, fmt.Errorf("failed to create requests duration histogram: %w", err)
4444
}

test/integration/vmcp/helpers/vmcp_server.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import (
1010

1111
"github.com/stacklok/toolhive/pkg/auth"
1212
"github.com/stacklok/toolhive/pkg/env"
13+
"github.com/stacklok/toolhive/pkg/telemetry"
1314
vmcptypes "github.com/stacklok/toolhive/pkg/vmcp"
1415
"github.com/stacklok/toolhive/pkg/vmcp/aggregator"
1516
"github.com/stacklok/toolhive/pkg/vmcp/auth/factory"
1617
authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
1718
vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client"
19+
"github.com/stacklok/toolhive/pkg/vmcp/composer"
1820
"github.com/stacklok/toolhive/pkg/vmcp/discovery"
1921
"github.com/stacklok/toolhive/pkg/vmcp/router"
2022
vmcpserver "github.com/stacklok/toolhive/pkg/vmcp/server"
@@ -63,8 +65,10 @@ type VMCPServerOption func(*vmcpServerConfig)
6365

6466
// vmcpServerConfig holds configuration for creating a test vMCP server.
6567
type vmcpServerConfig struct {
66-
conflictStrategy string
67-
prefixFormat string
68+
conflictStrategy string
69+
prefixFormat string
70+
workflowDefs map[string]*composer.WorkflowDefinition
71+
telemetryProvider *telemetry.Provider
6872
}
6973

7074
// WithPrefixConflictResolution configures prefix-based conflict resolution.
@@ -75,6 +79,20 @@ func WithPrefixConflictResolution(format string) VMCPServerOption {
7579
}
7680
}
7781

82+
// WithWorkflowDefinitions configures composite tool workflow definitions.
83+
func WithWorkflowDefinitions(defs map[string]*composer.WorkflowDefinition) VMCPServerOption {
84+
return func(c *vmcpServerConfig) {
85+
c.workflowDefs = defs
86+
}
87+
}
88+
89+
// WithTelemetryProvider configures the telemetry provider.
90+
func WithTelemetryProvider(provider *telemetry.Provider) VMCPServerOption {
91+
return func(c *vmcpServerConfig) {
92+
c.telemetryProvider = provider
93+
}
94+
}
95+
7896
// getFreePort returns an available TCP port on localhost.
7997
// This is used for parallel test execution to avoid port conflicts.
8098
func getFreePort(tb testing.TB) int {
@@ -148,12 +166,13 @@ func NewVMCPServer(
148166

149167
// Create vMCP server with test-specific defaults
150168
vmcpServer, err := vmcpserver.New(&vmcpserver.Config{
151-
Name: "test-vmcp",
152-
Version: "1.0.0",
153-
Host: "127.0.0.1",
154-
Port: getFreePort(tb), // Get a random available port for parallel test execution
155-
AuthMiddleware: auth.AnonymousMiddleware,
156-
}, rtr, backendClient, discoveryMgr, backends, nil) // nil for workflowDefs in tests
169+
Name: "test-vmcp",
170+
Version: "1.0.0",
171+
Host: "127.0.0.1",
172+
Port: getFreePort(tb), // Get a random available port for parallel test execution
173+
AuthMiddleware: auth.AnonymousMiddleware,
174+
TelemetryProvider: config.telemetryProvider,
175+
}, rtr, backendClient, discoveryMgr, backends, config.workflowDefs)
157176
require.NoError(tb, err, "failed to create vMCP server")
158177

159178
// Start server automatically

test/integration/vmcp/vmcp_integration_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ package vmcp_test
22

33
import (
44
"context"
5+
"io"
6+
"net/http"
7+
"strings"
58
"testing"
9+
"time"
610

711
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
813

14+
"github.com/stacklok/toolhive/pkg/telemetry"
915
"github.com/stacklok/toolhive/pkg/vmcp"
1016
authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
17+
"github.com/stacklok/toolhive/pkg/vmcp/composer"
1118
"github.com/stacklok/toolhive/test/integration/vmcp/helpers"
1219
)
1320

@@ -225,3 +232,123 @@ func TestVMCPServer_TwoBoundaryAuth_HeaderInjection(t *testing.T) {
225232
helpers.AssertTextNotContains(t, text, "error", "failed", "leakage")
226233
})
227234
}
235+
236+
// TestVMCPServer_Telemetry_CompositeToolMetrics verifies that vMCP exposes
237+
// Prometheus metrics for composite tool workflow executions and backend requests on /metrics.
238+
// This test creates a composite tool, executes it, and verifies the metrics
239+
// for both the workflow and the backend subtool calls are correctly exposed.
240+
func TestVMCPServer_Telemetry_CompositeToolMetrics(t *testing.T) {
241+
242+
ctx, cancel := context.WithCancel(context.Background())
243+
defer cancel()
244+
245+
// Setup: Create a synthetic MCP backend server with a simple tool
246+
echoServer := helpers.CreateBackendServer(t, []helpers.BackendTool{
247+
helpers.NewBackendTool("echo", "Echo the input message",
248+
func(_ context.Context, args map[string]any) string {
249+
msg, _ := args["message"].(string)
250+
return `{"echoed": "` + msg + `"}`
251+
}),
252+
}, helpers.WithBackendName("echo-mcp"))
253+
defer echoServer.Close()
254+
255+
// Configure backend pointing to test server
256+
backends := []vmcp.Backend{
257+
helpers.NewBackend("echo",
258+
helpers.WithURL(echoServer.URL+"/mcp"),
259+
helpers.WithMetadata("group", "test-group"),
260+
),
261+
}
262+
263+
// Create composite tool workflow definition that calls the echo tool
264+
workflowDefs := map[string]*composer.WorkflowDefinition{
265+
"echo_workflow": {
266+
Name: "echo_workflow",
267+
Description: "A composite tool that echoes a message",
268+
Parameters: map[string]any{
269+
"type": "object",
270+
"properties": map[string]any{
271+
"message": map[string]any{
272+
"type": "string",
273+
"description": "The message to echo",
274+
},
275+
},
276+
"required": []string{"message"},
277+
},
278+
Steps: []composer.WorkflowStep{
279+
{
280+
ID: "echo_step",
281+
Type: "tool",
282+
Tool: "echo_echo", // prefixed with backend name
283+
Arguments: map[string]any{
284+
"message": "{{.params.message}}",
285+
},
286+
},
287+
},
288+
Timeout: 30 * time.Second,
289+
},
290+
}
291+
292+
// Create telemetry provider with Prometheus enabled
293+
telemetryConfig := telemetry.Config{
294+
ServiceName: "vmcp-telemetry-test",
295+
ServiceVersion: "1.0.0",
296+
EnablePrometheusMetricsPath: true,
297+
}
298+
telemetryProvider, err := telemetry.NewProvider(ctx, telemetryConfig)
299+
require.NoError(t, err, "failed to create telemetry provider")
300+
defer telemetryProvider.Shutdown(ctx)
301+
302+
// Create vMCP server with composite tool and telemetry
303+
vmcpServer := helpers.NewVMCPServer(ctx, t, backends,
304+
helpers.WithPrefixConflictResolution("{workload}_"),
305+
helpers.WithWorkflowDefinitions(workflowDefs),
306+
helpers.WithTelemetryProvider(telemetryProvider),
307+
)
308+
309+
// Create and initialize MCP client
310+
vmcpURL := "http://" + vmcpServer.Address() + "/mcp"
311+
client := helpers.NewMCPClient(ctx, t, vmcpURL)
312+
defer client.Close()
313+
314+
// Call the composite tool
315+
resp := client.CallTool(ctx, "echo_workflow", map[string]any{"message": "hello world"})
316+
text := helpers.AssertToolCallSuccess(t, resp)
317+
helpers.AssertTextContains(t, text, "echoed", "hello world")
318+
319+
// Fetch metrics from /metrics endpoint
320+
metricsURL := "http://" + vmcpServer.Address() + "/metrics"
321+
httpClient := &http.Client{Timeout: 5 * time.Second}
322+
metricsResp, err := httpClient.Get(metricsURL)
323+
require.NoError(t, err, "failed to fetch metrics")
324+
defer metricsResp.Body.Close()
325+
326+
require.Equal(t, http.StatusOK, metricsResp.StatusCode, "metrics endpoint should return 200")
327+
328+
body, err := io.ReadAll(metricsResp.Body)
329+
require.NoError(t, err, "failed to read metrics body")
330+
metricsContent := string(body)
331+
332+
// Log metrics for debugging
333+
t.Logf("Metrics content:\n%s", metricsContent)
334+
335+
// Verify workflow execution metrics are present (composite tool).
336+
assert.True(t, strings.Contains(metricsContent, "toolhive_vmcp_workflow_executions_total"),
337+
"Should contain workflow executions total metric")
338+
assert.True(t, strings.Contains(metricsContent, "toolhive_vmcp_workflow_duration_seconds"),
339+
"Should contain workflow duration metric")
340+
assert.True(t, strings.Contains(metricsContent, `workflow_name="echo_workflow"`),
341+
"Should contain workflow name label")
342+
343+
// Verify backend metrics are present.
344+
assert.True(t, strings.Contains(metricsContent, "toolhive_vmcp_backend_requests_total"),
345+
"Should contain backend requests total metric")
346+
assert.True(t, strings.Contains(metricsContent, "toolhive_vmcp_backend_requests_duration"),
347+
"Should contain backend requests duration metric")
348+
349+
// Verify HTTP middleware metrics are present (incoming MCP requests).
350+
assert.True(t, strings.Contains(metricsContent, "toolhive_mcp_requests_total"),
351+
"Should contain HTTP middleware requests total metric")
352+
assert.True(t, strings.Contains(metricsContent, "toolhive_mcp_request_duration_seconds"),
353+
"Should contain HTTP middleware request duration metric")
354+
}

0 commit comments

Comments
 (0)