Skip to content

Commit 67f908e

Browse files
Support mcp_tool_invocation Counter and mcp_tool_invocation_duration Histogram (#54)
1 parent 36c138d commit 67f908e

8 files changed

Lines changed: 133 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"openai>=1.65.3",
2626
"pandas>=2.2.3",
2727
"pandas-stubs==2.3.0.250703",
28+
"prometheus-client>=0.22.1",
2829
"prompt-toolkit>=3.0.50",
2930
"pydantic>=2.10.6",
3031
"pydantic-settings>=2.8.1",

src/dremioai/metrics/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#
2+
# Copyright (C) 2017-2025 Dremio Corporation
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#

src/dremioai/metrics/registry.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#
2+
# Copyright (C) 2017-2025 Dremio Corporation
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
from prometheus_client import CollectorRegistry, make_asgi_app
17+
18+
_registry = CollectorRegistry()
19+
20+
21+
def get_metrics_app():
22+
global _registry
23+
return make_asgi_app(_registry)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#
2+
# Copyright (C) 2017-2025 Dremio Corporation
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
from prometheus_client import Counter, Histogram
17+
from dremioai.metrics.registry import _registry
18+
19+
invocation_counter = Counter(
20+
"mcp_tool_invocations",
21+
"Number of times a tool is invoked",
22+
["tool", "project_id"],
23+
registry=_registry,
24+
)
25+
invocation_duration = Histogram(
26+
"mcp_tool_invocation_duration",
27+
"Time taken to invoke a tool",
28+
["tool", "project_id"],
29+
registry=_registry,
30+
)

src/dremioai/servers/mcp.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from mcp.shared.auth import OAuthMetadata
2222
from pydantic import AnyHttpUrl
2323
from pydantic.networks import AnyUrl
24+
25+
from dremioai.metrics.registry import get_metrics_app
2426
from starlette.requests import Request
2527
from starlette.responses import Response
2628

@@ -117,6 +119,8 @@ def streamable_http_app(self):
117119
# like ../mcp/{project_id}/.. and extract that project id as
118120
# context var
119121
app.add_middleware(ProjectIdMiddleware)
122+
123+
app.mount("/metrics", get_metrics_app(), name="metrics")
120124
return app
121125

122126
def __init__(self, *args, **kwargs):

src/dremioai/tools/tools.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
#
16-
import json
1716
from contextvars import ContextVar
1817
from typing import (
1918
List,
@@ -34,7 +33,6 @@
3433

3534
from dataclasses import dataclass, asdict, field
3635

37-
from starlette.datastructures import URL
3836
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
3937
from starlette.requests import Request
4038

@@ -55,6 +53,7 @@
5553
from sqlglot import expressions
5654
from mcp.server.auth.middleware.auth_context import get_access_token
5755
from mcp.server.auth.provider import AccessToken
56+
from dremioai.metrics.tool_metrics import invocation_counter, invocation_duration
5857

5958
logger = log.logger(__name__)
6059

@@ -158,6 +157,23 @@ async def _impl(self, *args: P.args, **kw: P.kwargs) -> T:
158157
return _impl
159158

160159

160+
def with_metrics(fn: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
161+
@functools.wraps(fn)
162+
async def _impl(self, *args: P.args, **kw: P.kwargs) -> T:
163+
project_id = None
164+
if dremio := settings.instance().dremio:
165+
project_id = dremio.project_id
166+
invocation_counter.labels(
167+
project_id=project_id, tool=self.__class__.__name__
168+
).inc()
169+
with invocation_duration.labels(
170+
project_id=project_id, tool=self.__class__.__name__
171+
).time():
172+
return await fn(self, *args, **kw)
173+
174+
return _impl
175+
176+
161177
def _get_class_var_hints(tool: Tools, name: str) -> bool:
162178
if class_var := get_type_hints(tool, include_extras=True).get(name):
163179
if cls_args := get_args(class_var):
@@ -193,6 +209,7 @@ def group_by(self, df, by):
193209
return df.groupby(by).size().reset_index(name="count").to_dict(orient="records")
194210

195211
@secured
212+
@with_metrics
196213
async def invoke(self) -> Dict[str, Any]:
197214
"""Get the stats and details of failed or canceled jobs executed in the Dremio cluster in the past 7 days
198215
along with a split by job type
@@ -303,6 +320,7 @@ def ensure_query_allowed(s: str):
303320
)
304321

305322
@secured
323+
@with_metrics
306324
async def invoke(self, s: str) -> Dict[str, Union[List[Dict[Any, Any]] | str]]:
307325
"""Run a SELECT sql query on the Dremio cluster and return the results.
308326
Ensure that SQL keywords like 'day', 'month', 'count', 'table' etc are enclosed in double quotes
@@ -328,6 +346,7 @@ class BuildUsageReport(Tools):
328346
project_id_required: ClassVar[Annotated[bool, True]]
329347

330348
@secured
349+
@with_metrics
331350
async def invoke(
332351
self, by: Optional[Literal["PROJECT", "ENGINE"]] = "ENGINE"
333352
) -> Dict[str, Any]:
@@ -391,6 +410,7 @@ class GetSchemaOfTable(Tools):
391410
For: ClassVar[Annotated[ToolType, ToolType.FOR_SELF | ToolType.FOR_DATA_PATTERNS]]
392411

393412
@secured
413+
@with_metrics
394414
async def invoke(self, table_name: Union[str | List[str]]) -> Dict[str, Any]:
395415
"""Gets the schema of the given table.
396416
@@ -416,6 +436,7 @@ class GetTableOrViewLineage(Tools):
416436
For: ClassVar[Annotated[ToolType, ToolType.FOR_SELF | ToolType.FOR_DATA_PATTERNS]]
417437

418438
@secured
439+
@with_metrics
419440
async def invoke(self, table_name: Union[str, List[str]]) -> Dict[str, Any]:
420441
"""Finds the lineage of a table or view in the Dremio cluster
421442
@@ -437,6 +458,7 @@ class SearchTableAndViews(Tools):
437458
]
438459

439460
@secured
461+
@with_metrics
440462
async def invoke(self, query: str) -> Dict[str, Any]:
441463
"""Runs a semantic search on the Dremio cluster to find tables and views that match the query.
442464

tests/e2e/test_e2e_pat.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515
#
1616
import uuid
1717

18+
import httpx
1819
import pytest
1920
from mcp.types import CallToolResult
2021
from conftest import http_streamable_client_server, http_streamable_mcp_server
22+
from urllib.parse import urlparse
23+
from dremioai.config import settings
24+
25+
from dremioai.metrics.tool_metrics import invocation_counter
2126

2227

2328
@pytest.mark.asyncio
@@ -54,3 +59,23 @@ async def test_tool_pat(mock_config_dir, logging_server, logging_level, project_
5459
assert (
5560
str(project_id) in le.path
5661
), f"{le} does not have the right project id"
62+
63+
async with httpx.AsyncClient() as client:
64+
u = urlparse(sf.mcp_server.url)._replace(path="/metrics/").geturl()
65+
r = await client.get(u, headers={"Authorization": "Bearer my-token"})
66+
assert (
67+
r.status_code == 200
68+
), f"Error getting metrics: {r.text} status={r.status_code}"
69+
if project_id is None:
70+
project_id = settings.instance().dremio.project_id
71+
for line in r.text.splitlines():
72+
if (
73+
line.startswith(f"{invocation_counter._name}_total{{")
74+
and f'project_id="{project_id}"' in line
75+
):
76+
assert (
77+
float(line.split()[-1]) == 1.0
78+
), f"Invocation count not 1: {line}"
79+
break
80+
else:
81+
assert False, f"Invocation count for {project_id} not found in {r.text}"

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)