Skip to content

Commit 6a43a86

Browse files
[Feature Request]: Enhance Metrics Tab UI with Virtual Servers and Top 5 Performance Tables (#657)
* enhance metrics tab ui Signed-off-by: IrushaBasukala <[email protected]> * fixing error Signed-off-by: IrushaBasukala <[email protected]> * fixing build issues Signed-off-by: IrushaBasukala <[email protected]> * Fix many issues, rebase, still cannot properly add server Signed-off-by: Mihai Criveti <[email protected]> * Fix lint issues, rebae Signed-off-by: Mihai Criveti <[email protected]> * Fix lint issues, rebased Signed-off-by: Mihai Criveti <[email protected]> * Fixing the success rate Signed-off-by: Mihai Criveti <[email protected]> * Fixing the success rate Signed-off-by: Mihai Criveti <[email protected]> * Fix metrics display and calculations for tools, resources, and prompts - Fixed SQLite boolean comparisons (using == 1 instead of .is_(True)) - Fixed Float type casting in SQLAlchemy queries - Added division by zero protection - Fixed frontend JavaScript to handle camelCase field names from API - Added prompt metrics recording (partial - needs more work for auto-recording) - Eliminated code duplication with shared metrics_common.py utility - Fixed all pylint and flake8 issues The metrics now correctly display success rates and execution counts for all entity types. Signed-off-by: Mihai Criveti <[email protected]> * Fixing lint-web Signed-off-by: Mihai Criveti <[email protected]> * Improve test coverage Signed-off-by: Mihai Criveti <[email protected]> --------- Signed-off-by: IrushaBasukala <[email protected]> Signed-off-by: Mihai Criveti <[email protected]> Co-authored-by: Mihai Criveti <[email protected]>
1 parent 50a74a5 commit 6a43a86

17 files changed

+1332
-265
lines changed

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ contextmanager-decorators=contextlib.contextmanager
575575
# List of members which are set dynamically and missed by pylint inference
576576
# system, and so shouldn't trigger E1101 when accessed. Python regular
577577
# expressions are accepted.
578-
generated-members=
578+
generated-members=sqlalchemy.func.*
579579

580580
# Tells whether to warn about missing members when the owner of the attribute
581581
# is inferred to be None.

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ include Containerfile.lite
1313
include __init__
1414
include alembic.ini
1515
include tox.ini
16+
include alembic/README
1617

1718
# 2️⃣ Top-level config, examples and helper scripts
1819
include *.py

mcpgateway/admin.py

Lines changed: 67 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -3975,118 +3975,81 @@ async def admin_delete_root(uri: str, request: Request, user: str = Depends(requ
39753975
MetricsDict = Dict[str, Union[ToolMetrics, ResourceMetrics, ServerMetrics, PromptMetrics]]
39763976

39773977

3978-
@admin_router.get("/metrics", response_model=MetricsDict)
3979-
async def admin_get_metrics(
3978+
# @admin_router.get("/metrics", response_model=MetricsDict)
3979+
# async def admin_get_metrics(
3980+
# db: Session = Depends(get_db),
3981+
# user: str = Depends(require_auth),
3982+
# ) -> MetricsDict:
3983+
# """
3984+
# Retrieve aggregate metrics for all entity types via the admin UI.
3985+
3986+
# This endpoint collects and returns usage metrics for tools, resources, servers,
3987+
# and prompts. The metrics are retrieved by calling the aggregate_metrics method
3988+
# on each respective service, which compiles statistics about usage patterns,
3989+
# success rates, and other relevant metrics for administrative monitoring
3990+
# and analysis purposes.
3991+
3992+
# Args:
3993+
# db (Session): Database session dependency.
3994+
# user (str): Authenticated user dependency.
3995+
3996+
# Returns:
3997+
# MetricsDict: A dictionary containing the aggregated metrics for tools,
3998+
# resources, servers, and prompts. Each value is a Pydantic model instance
3999+
# specific to the entity type.
4000+
# """
4001+
# logger.debug(f"User {user} requested aggregate metrics")
4002+
# tool_metrics = await tool_service.aggregate_metrics(db)
4003+
# resource_metrics = await resource_service.aggregate_metrics(db)
4004+
# server_metrics = await server_service.aggregate_metrics(db)
4005+
# prompt_metrics = await prompt_service.aggregate_metrics(db)
4006+
4007+
# # Return actual Pydantic model instances
4008+
# return {
4009+
# "tools": tool_metrics,
4010+
# "resources": resource_metrics,
4011+
# "servers": server_metrics,
4012+
# "prompts": prompt_metrics,
4013+
# }
4014+
4015+
4016+
@admin_router.get("/metrics")
4017+
async def get_aggregated_metrics(
39804018
db: Session = Depends(get_db),
3981-
user: str = Depends(require_auth),
3982-
) -> MetricsDict:
3983-
"""
3984-
Retrieve aggregate metrics for all entity types via the admin UI.
4019+
_user: str = Depends(require_auth),
4020+
) -> Dict[str, Any]:
4021+
"""Retrieve aggregated metrics and top performers for all entity types.
39854022
3986-
This endpoint collects and returns usage metrics for tools, resources, servers,
3987-
and prompts. The metrics are retrieved by calling the aggregate_metrics method
3988-
on each respective service, which compiles statistics about usage patterns,
3989-
success rates, and other relevant metrics for administrative monitoring
3990-
and analysis purposes.
4023+
This endpoint collects usage metrics and top-performing entities for tools,
4024+
resources, prompts, and servers by calling the respective service methods.
4025+
The results are compiled into a dictionary for administrative monitoring.
39914026
39924027
Args:
3993-
db (Session): Database session dependency.
3994-
user (str): Authenticated user dependency.
4028+
db (Session): Database session dependency for querying metrics.
39954029
39964030
Returns:
3997-
MetricsDict: A dictionary containing the aggregated metrics for tools,
3998-
resources, servers, and prompts. Each value is a Pydantic model instance
3999-
specific to the entity type.
4000-
4001-
Examples:
4002-
>>> import asyncio
4003-
>>> from unittest.mock import AsyncMock, MagicMock
4004-
>>> from mcpgateway.schemas import ToolMetrics, ResourceMetrics, ServerMetrics, PromptMetrics
4005-
>>>
4006-
>>> mock_db = MagicMock()
4007-
>>> mock_user = "test_user"
4008-
>>>
4009-
>>> mock_tool_metrics = ToolMetrics(
4010-
... total_executions=10,
4011-
... successful_executions=9,
4012-
... failed_executions=1,
4013-
... failure_rate=0.1,
4014-
... min_response_time=0.05,
4015-
... max_response_time=1.0,
4016-
... avg_response_time=0.3,
4017-
... last_execution_time=None
4018-
... )
4019-
>>> mock_resource_metrics = ResourceMetrics(
4020-
... total_executions=5,
4021-
... successful_executions=5,
4022-
... failed_executions=0,
4023-
... failure_rate=0.0,
4024-
... min_response_time=0.1,
4025-
... max_response_time=0.5,
4026-
... avg_response_time=0.2,
4027-
... last_execution_time=None
4028-
... )
4029-
>>> mock_server_metrics = ServerMetrics(
4030-
... total_executions=7,
4031-
... successful_executions=7,
4032-
... failed_executions=0,
4033-
... failure_rate=0.0,
4034-
... min_response_time=0.2,
4035-
... max_response_time=0.7,
4036-
... avg_response_time=0.4,
4037-
... last_execution_time=None
4038-
... )
4039-
>>> mock_prompt_metrics = PromptMetrics(
4040-
... total_executions=3,
4041-
... successful_executions=3,
4042-
... failed_executions=0,
4043-
... failure_rate=0.0,
4044-
... min_response_time=0.15,
4045-
... max_response_time=0.6,
4046-
... avg_response_time=0.35,
4047-
... last_execution_time=None
4048-
... )
4049-
>>>
4050-
>>> original_aggregate_metrics_tool = tool_service.aggregate_metrics
4051-
>>> original_aggregate_metrics_resource = resource_service.aggregate_metrics
4052-
>>> original_aggregate_metrics_server = server_service.aggregate_metrics
4053-
>>> original_aggregate_metrics_prompt = prompt_service.aggregate_metrics
4054-
>>>
4055-
>>> tool_service.aggregate_metrics = AsyncMock(return_value=mock_tool_metrics)
4056-
>>> resource_service.aggregate_metrics = AsyncMock(return_value=mock_resource_metrics)
4057-
>>> server_service.aggregate_metrics = AsyncMock(return_value=mock_server_metrics)
4058-
>>> prompt_service.aggregate_metrics = AsyncMock(return_value=mock_prompt_metrics)
4059-
>>>
4060-
>>> async def test_admin_get_metrics():
4061-
... result = await admin_get_metrics(mock_db, mock_user)
4062-
... return (
4063-
... isinstance(result, dict) and
4064-
... result.get("tools") == mock_tool_metrics and
4065-
... result.get("resources") == mock_resource_metrics and
4066-
... result.get("servers") == mock_server_metrics and
4067-
... result.get("prompts") == mock_prompt_metrics
4068-
... )
4069-
>>>
4070-
>>> import asyncio; asyncio.run(test_admin_get_metrics())
4071-
True
4072-
>>>
4073-
>>> tool_service.aggregate_metrics = original_aggregate_metrics_tool
4074-
>>> resource_service.aggregate_metrics = original_aggregate_metrics_resource
4075-
>>> server_service.aggregate_metrics = original_aggregate_metrics_server
4076-
>>> prompt_service.aggregate_metrics = original_aggregate_metrics_prompt
4031+
Dict[str, Any]: A dictionary containing aggregated metrics and top performers
4032+
for tools, resources, prompts, and servers. The structure includes:
4033+
- 'tools': Metrics for tools.
4034+
- 'resources': Metrics for resources.
4035+
- 'prompts': Metrics for prompts.
4036+
- 'servers': Metrics for servers.
4037+
- 'topPerformers': A nested dictionary with top 5 tools, resources, prompts,
4038+
and servers.
40774039
"""
4078-
logger.debug(f"User {user} requested aggregate metrics")
4079-
tool_metrics = await tool_service.aggregate_metrics(db)
4080-
resource_metrics = await resource_service.aggregate_metrics(db)
4081-
server_metrics = await server_service.aggregate_metrics(db)
4082-
prompt_metrics = await prompt_service.aggregate_metrics(db)
4083-
4084-
return {
4085-
"tools": tool_metrics,
4086-
"resources": resource_metrics,
4087-
"servers": server_metrics,
4088-
"prompts": prompt_metrics,
4040+
metrics = {
4041+
"tools": await tool_service.aggregate_metrics(db),
4042+
"resources": await resource_service.aggregate_metrics(db),
4043+
"prompts": await prompt_service.aggregate_metrics(db),
4044+
"servers": await server_service.aggregate_metrics(db),
4045+
"topPerformers": {
4046+
"tools": await tool_service.get_top_tools(db, limit=5),
4047+
"resources": await resource_service.get_top_resources(db, limit=5),
4048+
"prompts": await prompt_service.get_top_prompts(db, limit=5),
4049+
"servers": await server_service.get_top_servers(db, limit=5),
4050+
},
40894051
}
4052+
return metrics
40904053

40914054

40924055
@admin_router.post("/metrics/reset", response_model=Dict[str, object])

mcpgateway/main.py

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import asyncio
3030
from contextlib import asynccontextmanager
3131
import json
32+
import time
3233
from typing import Any, AsyncIterator, Dict, List, Optional, Union
3334
from urllib.parse import urlparse, urlunparse
3435

@@ -52,7 +53,7 @@
5253
from fastapi.staticfiles import StaticFiles
5354
from fastapi.templating import Jinja2Templates
5455
from pydantic import ValidationError
55-
from sqlalchemy import text
56+
from sqlalchemy import select, text
5657
from sqlalchemy.exc import IntegrityError
5758
from sqlalchemy.orm import Session
5859
from starlette.middleware.base import BaseHTTPMiddleware
@@ -64,7 +65,8 @@
6465
from mcpgateway.bootstrap_db import main as bootstrap_db
6566
from mcpgateway.cache import ResourceCache, SessionRegistry
6667
from mcpgateway.config import jsonpath_modifier, settings
67-
from mcpgateway.db import refresh_slugs_on_startup, SessionLocal
68+
from mcpgateway.db import Prompt as DbPrompt
69+
from mcpgateway.db import PromptMetric, refresh_slugs_on_startup, SessionLocal
6870
from mcpgateway.handlers.sampling import SamplingHandler
6971
from mcpgateway.models import (
7072
InitializeRequest,
@@ -1780,17 +1782,49 @@ async def get_prompt(
17801782
17811783
Returns:
17821784
Rendered prompt or metadata.
1785+
1786+
Raises:
1787+
Exception: Re-raised if not a handled exception type.
17831788
"""
17841789
logger.debug(f"User: {user} requested prompt: {name} with args={args}")
1790+
start_time = time.monotonic()
1791+
success = False
1792+
error_message = None
1793+
result = None
1794+
17851795
try:
17861796
PromptExecuteArgs(args=args)
1787-
return await prompt_service.get_prompt(db, name, args)
1797+
result = await prompt_service.get_prompt(db, name, args)
1798+
success = True
1799+
logger.debug(f"Prompt execution successful for '{name}'")
17881800
except Exception as ex:
1801+
error_message = str(ex)
17891802
logger.error(f"Could not retrieve prompt {name}: {ex}")
17901803
if isinstance(ex, (ValueError, PromptError)):
1791-
return JSONResponse(content={"message": "Prompt execution arguments contains HTML tags that may cause security issues"}, status_code=422)
1792-
if isinstance(ex, PluginViolationError):
1793-
return JSONResponse(content={"message": "Prompt execution arguments contains HTML tags that may cause security issues", "details": ex.message}, status_code=422)
1804+
result = JSONResponse(content={"message": "Prompt execution arguments contains HTML tags that may cause security issues"}, status_code=422)
1805+
elif isinstance(ex, PluginViolationError):
1806+
result = JSONResponse(content={"message": "Prompt execution arguments contains HTML tags that may cause security issues", "details": ex.message}, status_code=422)
1807+
else:
1808+
raise
1809+
1810+
# Record metrics (moved outside try/except/finally to ensure it runs)
1811+
end_time = time.monotonic()
1812+
response_time = end_time - start_time
1813+
1814+
# Get the prompt from database to get its ID
1815+
prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name)).scalar_one_or_none()
1816+
1817+
if prompt:
1818+
metric = PromptMetric(
1819+
prompt_id=prompt.id,
1820+
response_time=response_time,
1821+
is_success=success,
1822+
error_message=error_message,
1823+
)
1824+
db.add(metric)
1825+
db.commit()
1826+
1827+
return result
17941828

17951829

17961830
@prompt_router.get("/{name}")
@@ -1810,9 +1844,41 @@ async def get_prompt_no_args(
18101844
18111845
Returns:
18121846
The prompt template information
1847+
1848+
Raises:
1849+
Exception: Re-raised from prompt service.
18131850
"""
18141851
logger.debug(f"User: {user} requested prompt: {name} with no arguments")
1815-
return await prompt_service.get_prompt(db, name, {})
1852+
start_time = time.monotonic()
1853+
success = False
1854+
error_message = None
1855+
result = None
1856+
1857+
try:
1858+
result = await prompt_service.get_prompt(db, name, {})
1859+
success = True
1860+
except Exception as ex:
1861+
error_message = str(ex)
1862+
raise
1863+
1864+
# Record metrics
1865+
end_time = time.monotonic()
1866+
response_time = end_time - start_time
1867+
1868+
# Get the prompt from database to get its ID
1869+
prompt = db.execute(select(DbPrompt).where(DbPrompt.name == name)).scalar_one_or_none()
1870+
1871+
if prompt:
1872+
metric = PromptMetric(
1873+
prompt_id=prompt.id,
1874+
response_time=response_time,
1875+
is_success=success,
1876+
error_message=error_message,
1877+
)
1878+
db.add(metric)
1879+
db.commit()
1880+
1881+
return result
18161882

18171883

18181884
@prompt_router.put("/{name}", response_model=PromptRead)

mcpgateway/schemas.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ def prevent_manual_mcp_creation(cls, values: Dict[str, Any]) -> Dict[str, Any]:
590590
"""
591591
integration_type = values.get("integration_type")
592592
if integration_type == "MCP":
593-
raise ValueError("Cannot manually create MCP tools. Add MCP servers via the Gateways interface - " "tools will be auto-discovered and registered with integration_type='MCP'.")
593+
raise ValueError("Cannot manually create MCP tools. Add MCP servers via the Gateways interface - tools will be auto-discovered and registered with integration_type='MCP'.")
594594
return values
595595

596596

@@ -2936,3 +2936,26 @@ class TagInfo(BaseModelWithConfigDict):
29362936
name: str = Field(..., description="The tag name")
29372937
stats: TagStats = Field(..., description="Statistics for this tag")
29382938
entities: Optional[List[TaggedEntity]] = Field(default_factory=list, description="Entities that have this tag")
2939+
2940+
2941+
class TopPerformer(BaseModelWithConfigDict):
2942+
"""Schema for representing top-performing entities with performance metrics.
2943+
2944+
Used to encapsulate metrics for entities such as prompts, resources, servers, or tools,
2945+
including execution count, average response time, success rate, and last execution timestamp.
2946+
2947+
Attributes:
2948+
id (Union[str, int]): Unique identifier for the entity.
2949+
name (str): Name of the entity (e.g., prompt name, resource URI, server name, or tool name).
2950+
execution_count (int): Total number of executions for the entity.
2951+
avg_response_time (Optional[float]): Average response time in seconds, or None if no metrics.
2952+
success_rate (Optional[float]): Success rate percentage, or None if no metrics.
2953+
last_execution (Optional[datetime]): Timestamp of the last execution, or None if no metrics.
2954+
"""
2955+
2956+
id: Union[str, int] = Field(..., description="Entity ID")
2957+
name: str = Field(..., description="Entity name")
2958+
execution_count: int = Field(..., description="Number of executions")
2959+
avg_response_time: Optional[float] = Field(None, description="Average response time in seconds")
2960+
success_rate: Optional[float] = Field(None, description="Success rate percentage")
2961+
last_execution: Optional[datetime] = Field(None, description="Timestamp of last execution")

mcpgateway/services/logging_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ async def initialize(self) -> None:
123123
try:
124124
root_logger.addHandler(_get_file_handler())
125125
if settings.log_rotation_enabled:
126-
logging.info(f"File logging enabled with rotation: {settings.log_folder or '.'}/{settings.log_file} " f"(max: {settings.log_max_size_mb}MB, backups: {settings.log_backup_count})")
126+
logging.info(f"File logging enabled with rotation: {settings.log_folder or '.'}/{settings.log_file} (max: {settings.log_max_size_mb}MB, backups: {settings.log_backup_count})")
127127
else:
128128
logging.info(f"File logging enabled (no rotation): {settings.log_folder or '.'}/{settings.log_file}")
129129
except Exception as e:

0 commit comments

Comments
 (0)