Skip to main content
Glama

Last9 Observability MCP

Official
Apache 2.0
122
45
  • Apple
get_traces_test.go11.3 kB
package traces import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "last9-mcp/internal/models" "last9-mcp/internal/utils" "github.com/modelcontextprotocol/go-sdk/mcp" ) var ( testBaseURL = "https://otlp-aps1.last9.io:443" testAuthToken = os.Getenv("TEST_AUTH_TOKEN") testRefreshToken = os.Getenv("TEST_REFRESH_TOKEN") ) func TestValidateGetTracesArgs(t *testing.T) { tests := []struct { name string args GetTracesArgs wantErr bool errMsg string }{ { name: "Both trace_id and service_name empty", args: GetTracesArgs{}, wantErr: true, errMsg: "either trace_id or service_name must be provided", }, { name: "Both trace_id and service_name provided", args: GetTracesArgs{ TraceID: "abc123", ServiceName: "test-service", }, wantErr: true, errMsg: "cannot specify both trace_id and service_name", }, { name: "Only trace_id provided - valid", args: GetTracesArgs{ TraceID: "abc123def456", }, wantErr: false, }, { name: "Only service_name provided - valid", args: GetTracesArgs{ ServiceName: "test-service", }, wantErr: false, }, { name: "Invalid lookback_minutes - too small", args: GetTracesArgs{ ServiceName: "test-service", LookbackMinutes: 0.5, }, wantErr: true, errMsg: "lookback_minutes must be between 1 and 1440", }, { name: "Invalid lookback_minutes - too large", args: GetTracesArgs{ ServiceName: "test-service", LookbackMinutes: 1500, }, wantErr: true, errMsg: "lookback_minutes must be between 1 and 1440", }, { name: "Invalid limit - too small", args: GetTracesArgs{ ServiceName: "test-service", Limit: 0.5, }, wantErr: true, errMsg: "limit must be between 1 and 100", }, { name: "Invalid limit - too large", args: GetTracesArgs{ ServiceName: "test-service", Limit: 150, }, wantErr: true, errMsg: "limit must be between 1 and 100", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateGetTracesArgs(tt.args) if (err != nil) != tt.wantErr { t.Errorf("validateGetTracesArgs() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("validateGetTracesArgs() error = %v, want error containing %v", err, tt.errMsg) } }) } } func TestParseGetTracesParams(t *testing.T) { cfg := models.Config{ BaseURL: testBaseURL, } tests := []struct { name string args GetTracesArgs wantErr bool wantTrace string wantSvc string wantLimit int }{ { name: "Valid trace ID request", args: GetTracesArgs{ TraceID: "abc123def456", Limit: 20, }, wantErr: false, wantTrace: "abc123def456", wantLimit: 20, }, { name: "Valid service name request with defaults", args: GetTracesArgs{ ServiceName: "payment-service", }, wantErr: false, wantSvc: "payment-service", wantLimit: LimitDefault, }, { name: "Valid service name with custom params", args: GetTracesArgs{ ServiceName: "api-service", LookbackMinutes: 30, Limit: 5, Env: "prod", }, wantErr: false, wantSvc: "api-service", wantLimit: 5, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseGetTracesParams(tt.args, cfg) if (err != nil) != tt.wantErr { t.Errorf("parseGetTracesParams() error = %v, wantErr %v", err, tt.wantErr) return } if err == nil { if result.TraceID != tt.wantTrace { t.Errorf("parseGetTracesParams() TraceID = %v, want %v", result.TraceID, tt.wantTrace) } if result.ServiceName != tt.wantSvc { t.Errorf("parseGetTracesParams() ServiceName = %v, want %v", result.ServiceName, tt.wantSvc) } if result.Limit != tt.wantLimit { t.Errorf("parseGetTracesParams() Limit = %v, want %v", result.Limit, tt.wantLimit) } } }) } } func TestBuildGetTracesFilters(t *testing.T) { tests := []struct { name string params *GetTracesQueryParams wantLen int wantCond string }{ { name: "Trace ID filter", params: &GetTracesQueryParams{ TraceID: "abc123def456", }, wantLen: 1, wantCond: "TraceId", }, { name: "Service name filter", params: &GetTracesQueryParams{ ServiceName: "test-service", }, wantLen: 1, wantCond: "ServiceName", }, { name: "Service name with environment filter", params: &GetTracesQueryParams{ ServiceName: "test-service", Env: "prod", }, wantLen: 2, wantCond: "ServiceName", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { filters := buildGetTracesFilters(tt.params) if len(filters) != tt.wantLen { t.Errorf("buildGetTracesFilters() len = %v, want %v", len(filters), tt.wantLen) } // Check that the main filter condition exists found := false for _, filter := range filters { if eq, ok := filter["$eq"].([]interface{}); ok && len(eq) >= 2 { if field, ok := eq[0].(string); ok && field == tt.wantCond { found = true break } } } if !found { t.Errorf("buildGetTracesFilters() missing expected condition %v", tt.wantCond) } }) } } func TestGetTracesHandler_MockedResponse(t *testing.T) { // Mock API response mockResponse := `{ "data": { "result": [ { "TraceId": "abc123def456", "SpanId": "span789", "SpanKind": "SPAN_KIND_SERVER", "SpanName": "GET /api/users", "ServiceName": "api-service", "Duration": 150000000, "Timestamp": "2025-11-02T10:00:00Z", "TraceState": "", "StatusCode": "STATUS_CODE_OK" }, { "TraceId": "def789ghi012", "SpanId": "span456", "SpanKind": "SPAN_KIND_CLIENT", "SpanName": "db_query", "ServiceName": "api-service", "Duration": 25000000, "Timestamp": "2025-11-02T10:00:01Z", "TraceState": "", "StatusCode": "STATUS_CODE_OK" } ] } }` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify request method and path if r.Method != "POST" { t.Errorf("Expected POST request, got %s", r.Method) } if !strings.Contains(r.URL.Path, "/cat/api/traces/v2/query_range/json") { t.Errorf("Expected traces API path, got %s", r.URL.Path) } // Verify request body contains expected filters body, _ := io.ReadAll(r.Body) var req TraceQueryRequest json.Unmarshal(body, &req) if len(req.Pipeline) == 0 { t.Error("Expected pipeline in request") } w.WriteHeader(http.StatusOK) io.WriteString(w, mockResponse) })) defer server.Close() cfg := models.Config{ APIBaseURL: server.URL, BaseURL: testBaseURL, AuthToken: "test-token", RefreshToken: testRefreshToken, } handler := GetTracesHandler(server.Client(), cfg) tests := []struct { name string args GetTracesArgs wantErr bool }{ { name: "Get traces by service name", args: GetTracesArgs{ ServiceName: "api-service", LookbackMinutes: 60, Limit: 10, }, wantErr: false, }, { name: "Get traces by trace ID", args: GetTracesArgs{ TraceID: "abc123def456", Limit: 5, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() req := &mcp.CallToolRequest{} result, _, err := handler(ctx, req, tt.args) if (err != nil) != tt.wantErr { t.Errorf("GetTracesHandler() error = %v, wantErr %v", err, tt.wantErr) return } if err == nil { if len(result.Content) == 0 { t.Fatalf("expected content in result") } textContent, ok := result.Content[0].(*mcp.TextContent) if !ok { t.Fatalf("expected TextContent type") } var traceResponse TraceQueryResponse if err := json.Unmarshal([]byte(textContent.Text), &traceResponse); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if !traceResponse.Success { t.Errorf("expected successful response, got success=%v", traceResponse.Success) } if len(traceResponse.Data) == 0 { t.Error("expected trace data in response") } // Verify trace data structure for _, trace := range traceResponse.Data { if trace.TraceID == "" { t.Error("expected TraceID to be populated") } if trace.ServiceName == "" { t.Error("expected ServiceName to be populated") } } } }) } } func TestGetTracesHandler_ValidationErrors(t *testing.T) { cfg := models.Config{ BaseURL: testBaseURL, AuthToken: "test-token", } handler := GetTracesHandler(http.DefaultClient, cfg) tests := []struct { name string args GetTracesArgs wantErr bool errMsg string }{ { name: "Missing both trace_id and service_name", args: GetTracesArgs{}, wantErr: true, errMsg: "either trace_id or service_name must be provided", }, { name: "Both trace_id and service_name provided", args: GetTracesArgs{ TraceID: "abc123", ServiceName: "test-service", }, wantErr: true, errMsg: "cannot specify both trace_id and service_name", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() req := &mcp.CallToolRequest{} _, _, err := handler(ctx, req, tt.args) if (err != nil) != tt.wantErr { t.Errorf("GetTracesHandler() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("GetTracesHandler() error = %v, want error containing %v", err, tt.errMsg) } }) } } // Integration test - requires real API credentials func TestGetTracesHandler_Integration(t *testing.T) { if testAuthToken == "" || testRefreshToken == "" { t.Skip("Skipping integration test: TEST_AUTH_TOKEN or TEST_REFRESH_TOKEN not set") } cfg := models.Config{ BaseURL: testBaseURL, AuthToken: testAuthToken, RefreshToken: testRefreshToken, } if err := utils.PopulateAPICfg(&cfg); err != nil { t.Fatalf("failed to refresh access token: %v", err) } handler := GetTracesHandler(http.DefaultClient, cfg) // Test with service name args := GetTracesArgs{ ServiceName: "test-service", // Replace with actual service name for real testing LookbackMinutes: 60, Limit: 5, } ctx := context.Background() req := &mcp.CallToolRequest{} result, _, err := handler(ctx, req, args) if err != nil { // Integration test may fail if service doesn't exist - that's ok t.Logf("Integration test failed (expected if test service doesn't exist): %v", err) return } if len(result.Content) == 0 { t.Fatalf("expected content in result") } textContent, ok := result.Content[0].(*mcp.TextContent) if !ok { t.Fatalf("expected TextContent type") } var traceResponse TraceQueryResponse if err := json.Unmarshal([]byte(textContent.Text), &traceResponse); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } t.Logf("Integration test successful: received %d traces", len(traceResponse.Data)) }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/last9/last9-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server