Skip to content

Commit e997513

Browse files
feat(plugins): add Tools Telemetry Exporter plugin (#1538)
Add new plugin that exports comprehensive tool invocation telemetry to OpenTelemetry for observability and monitoring. Features: - Pre-invocation telemetry: request context, tool metadata, target MCP server details, and tool arguments - Post-invocation telemetry: request context, tool results (optional), and error status - Automatic payload truncation to respect size limits - Graceful degradation when OpenTelemetry is not available Configuration options: - export_full_payload: Export full tool results in post-invocation - max_payload_bytes_size: Maximum payload size before truncation The plugin is disabled by default and requires OpenTelemetry to be enabled on the gateway. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
1 parent 3de4f72 commit e997513

File tree

6 files changed

+321
-0
lines changed

6 files changed

+321
-0
lines changed

docs/docs/using/plugins/plugins.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ MCP Context Forge provides a comprehensive collection of production-ready plugin
66

77
- [Security & Safety](#security-safety)
88
- [Reliability & Performance](#reliability-performance)
9+
- [Observability & Monitoring](#observability-monitoring)
910
- [Content Transformation & Formatting](#content-transformation-formatting)
1011
- [Content Filtering & Validation](#content-filtering-validation)
1112
- [Compliance & Governance](#compliance-governance)
@@ -44,6 +45,14 @@ Plugins for improving system reliability, performance, and resource management.
4445
| [Response Cache by Prompt](https://github.com/IBM/mcp-context-forge/tree/main/plugins/response_cache_by_prompt) | Native | Advisory response cache using cosine similarity over prompt/input fields with configurable threshold |
4546
| [Retry with Backoff](https://github.com/IBM/mcp-context-forge/tree/main/plugins/retry_with_backoff) | Native | Annotates retry/backoff policy in metadata with exponential backoff on specific HTTP status codes |
4647

48+
## Observability & Monitoring
49+
50+
Plugins for telemetry, tracing, and monitoring tool invocations.
51+
52+
| Plugin | Type | Description |
53+
|--------|------|-------------|
54+
| [Tools Telemetry Exporter](https://github.com/IBM/mcp-context-forge/tree/main/plugins/tools_telemetry_exporter) | Native | Export comprehensive tool invocation telemetry to OpenTelemetry for observability and monitoring with configurable payload export |
55+
4756
## Content Transformation & Formatting
4857

4958
Plugins for transforming, formatting, and normalizing content.

plugins/config.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,3 +899,18 @@ plugins:
899899
api_key: "" # optional, can define ANTHROPIC_API_KEY instead
900900
model_id: "ibm/granite-3-3-8b-instruct" # note that this changes depending on provider
901901
length_threshold: 100000
902+
903+
# Tools Telemetry Exporter - export tool invocation telemetry to OpenTelemetry
904+
- name: "ToolsTelemetryExporter"
905+
kind: "plugins.tools_telemetry_exporter.telemetry_exporter.ToolsTelemetryExporterPlugin"
906+
description: "Export comprehensive tool invocation telemetry to OpenTelemetry"
907+
version: "0.1.0"
908+
author: "Bar Haim"
909+
hooks: ["tool_pre_invoke", "tool_post_invoke"]
910+
tags: ["telemetry", "observability", "opentelemetry", "monitoring"]
911+
mode: "disabled" # enforce | permissive | disabled
912+
priority: 200 # Run late to capture all context
913+
conditions: [] # Apply to all tools
914+
config:
915+
export_full_payload: true
916+
max_payload_bytes_size: 10000
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Tools Telemetry Exporter Plugin
2+
3+
> Author: Bar Haim
4+
> Version: 0.1.0
5+
6+
Export comprehensive tool invocation telemetry to OpenTelemetry for observability and monitoring.
7+
8+
## Hooks
9+
- `tool_pre_invoke`
10+
- `tool_post_invoke`
11+
12+
## Config
13+
```yaml
14+
config:
15+
export_full_payload: true
16+
max_payload_bytes_size: 10000 # 10 KB default
17+
```
18+
19+
## Features
20+
21+
- **Pre-Invocation Telemetry**: Captures request context, tool metadata, target MCP server details, and tool arguments
22+
- **Post-Invocation Telemetry**: Captures request context, tool results (optional), and error status
23+
- **Automatic Payload Truncation**: Large results are truncated to respect size limits
24+
- **Graceful Degradation**: Automatically disables if OpenTelemetry is not available
25+
26+
## Exported Attributes
27+
28+
### Pre-Invocation (`tool.pre_invoke`)
29+
- Request metadata: `request_id`, `user`, `tenant_id`, `server_id`
30+
- Target server: `target_mcp_server.id`, `target_mcp_server.name`, `target_mcp_server.url`
31+
- Tool info: `tool.name`, `tool.target_tool_name`, `tool.description`
32+
- Invocation data: `tool.invocation.args`, `headers`
33+
34+
### Post-Invocation (`tool.post_invoke`)
35+
- Request metadata: `request_id`, `user`, `tenant_id`, `server_id`
36+
- Results: `tool.invocation.result` (if `export_full_payload` is enabled and no error)
37+
- Status: `tool.invocation.has_error`
38+
39+
## Configuration Options
40+
41+
| Option | Default | Description |
42+
|--------|---------|-------------|
43+
| `export_full_payload` | `true` | Export full tool results in post-invocation telemetry |
44+
| `max_payload_bytes_size` | `10000` | Maximum payload size in bytes before truncation |
45+
46+
## Requirements
47+
48+
OpenTelemetry enabled on MCP context forge (see [Observability Setup](../../docs/docs/manage/observability.md#opentelemetry-external)).
49+
50+
51+
## Usage
52+
53+
```yaml
54+
plugins:
55+
- name: "ToolsTelemetryExporter"
56+
kind: "plugins.tools_telemetry_exporter.telemetry_exporter.ToolsTelemetryExporterPlugin"
57+
hooks: ["tool_pre_invoke", "tool_post_invoke"]
58+
mode: "permissive"
59+
priority: 200 # Run late to capture all context
60+
config:
61+
export_full_payload: true
62+
max_payload_bytes_size: 10000
63+
```
64+
65+
## Limitations
66+
67+
- Requires active OpenTelemetry tracing to export telemetry
68+
- No local buffering; telemetry exported in real-time only
69+
70+
## Security Notes
71+
72+
- Tool arguments are always exported in pre-invocation telemetry
73+
- Consider running PII filter plugin before this plugin to sanitize data
74+
- Disable `export_full_payload` in production for sensitive workloads
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Tools Telemetry Exporter Plugin - exports tool invocation telemetry to OpenTelemetry."""
2+
3+
from plugins.tools_telemetry_exporter.telemetry_exporter import ToolsTelemetryExporterPlugin
4+
5+
__all__ = ["ToolsTelemetryExporterPlugin"]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
description: "Export comprehensive tool invocation telemetry to OpenTelemetry"
2+
author: "Bar Haim"
3+
version: "0.1.0"
4+
available_hooks:
5+
- "tool_pre_invoke"
6+
- "tool_post_invoke"
7+
default_configs:
8+
export_full_payload: true
9+
max_payload_bytes_size: 10000 # (10 KB default)
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./plugins/tools_telemetry_exporter/telemetry_exporter.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
6+
Tools Telemetry Exporter Plugin.
7+
This plugin exports comprehensive tool invocation telemetry to OpenTelemetry.
8+
"""
9+
10+
# Standard
11+
import json
12+
from typing import Dict
13+
14+
# First-Party
15+
from mcpgateway.common.models import Gateway, Tool
16+
from mcpgateway.plugins.framework import Plugin, PluginConfig, PluginContext
17+
from mcpgateway.plugins.framework.constants import GATEWAY_METADATA, TOOL_METADATA
18+
from mcpgateway.plugins.framework.hooks.tools import ToolPostInvokePayload, ToolPostInvokeResult, ToolPreInvokePayload, ToolPreInvokeResult
19+
from mcpgateway.services.logging_service import LoggingService
20+
21+
# Initialize logging service first
22+
logging_service = LoggingService()
23+
logger = logging_service.get_logger(__name__)
24+
25+
26+
class ToolsTelemetryExporterPlugin(Plugin):
27+
"""Export comprehensive tool invocation telemetry to OpenTelemetry."""
28+
29+
def __init__(self, config: PluginConfig):
30+
"""Initialize the ToolsTelemetryExporterPlugin.
31+
32+
Args:
33+
config: Plugin configuration containing telemetry settings.
34+
"""
35+
super().__init__(config)
36+
self.is_open_telemetry_available = self._is_open_telemetry_available()
37+
self.telemetry_config = config.config
38+
39+
@staticmethod
40+
def _is_open_telemetry_available() -> bool:
41+
"""Check if OpenTelemetry is available for import.
42+
43+
Returns:
44+
True if OpenTelemetry can be imported, False otherwise.
45+
"""
46+
try:
47+
# Third-Party
48+
from opentelemetry import trace # noqa: F401 # pylint: disable=import-outside-toplevel,unused-import
49+
50+
return True
51+
except ImportError:
52+
logger.warning("ToolsTelemetryExporter: OpenTelemetry is not available. Telemetry export will be disabled.")
53+
return False
54+
55+
@staticmethod
56+
def _get_base_context_attributes(context: PluginContext) -> Dict:
57+
"""Extract base context attributes from plugin context.
58+
59+
Args:
60+
context: Plugin execution context containing global context.
61+
62+
Returns:
63+
Dictionary with base attributes (request_id, user, tenant_id, server_id).
64+
"""
65+
global_context = context.global_context
66+
return {
67+
"request_id": global_context.request_id or "",
68+
"user": global_context.user or "",
69+
"tenant_id": global_context.tenant_id or "",
70+
"server_id": global_context.server_id or "",
71+
}
72+
73+
def _get_pre_invoke_context_attributes(self, context: PluginContext) -> Dict:
74+
"""Extract pre-invocation context attributes including tool and gateway metadata.
75+
76+
Args:
77+
context: Plugin execution context containing tool and gateway metadata.
78+
79+
Returns:
80+
Dictionary with base attributes plus tool and target MCP server details.
81+
"""
82+
global_context = context.global_context
83+
tool_metadata: Tool = global_context.metadata.get(TOOL_METADATA)
84+
target_mcp_server_metadata: Gateway = global_context.metadata.get(GATEWAY_METADATA)
85+
86+
return {
87+
**self._get_base_context_attributes(context),
88+
"tool": {
89+
"name": tool_metadata.name or "",
90+
"target_tool_name": tool_metadata.original_name or "",
91+
"description": tool_metadata.description or "",
92+
},
93+
"target_mcp_server": {
94+
"id": target_mcp_server_metadata.id or "",
95+
"name": target_mcp_server_metadata.name or "",
96+
"url": str(target_mcp_server_metadata.url or ""),
97+
},
98+
}
99+
100+
def _get_post_invoke_context_attributes(self, context: PluginContext) -> Dict:
101+
"""Extract post-invocation context attributes.
102+
103+
Args:
104+
context: Plugin execution context.
105+
106+
Returns:
107+
Dictionary with base context attributes for post-invocation telemetry.
108+
"""
109+
return {
110+
**self._get_base_context_attributes(context),
111+
}
112+
113+
async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult:
114+
"""Capture pre-invocation telemetry for tools.
115+
116+
Args:
117+
payload: The tool payload containing arguments.
118+
context: Plugin execution context.
119+
120+
Returns:
121+
Result with potentially modified tool arguments.
122+
"""
123+
logger.info("ToolsTelemetryExporter: Capturing pre-invocation tool telemetry.")
124+
context_attributes = self._get_pre_invoke_context_attributes(context)
125+
126+
export_attributes = {
127+
"request_id": context_attributes["request_id"],
128+
"user": context_attributes["user"],
129+
"tenant_id": context_attributes["tenant_id"],
130+
"server_id": context_attributes["server_id"],
131+
"target_mcp_server.id": context_attributes["target_mcp_server"]["id"],
132+
"target_mcp_server.name": context_attributes["target_mcp_server"]["name"],
133+
"target_mcp_server.url": context_attributes["target_mcp_server"]["url"],
134+
"tool.name": context_attributes["tool"]["name"],
135+
"tool.target_tool_name": context_attributes["tool"]["target_tool_name"],
136+
"tool.description": context_attributes["tool"]["description"],
137+
"tool.invocation.args": json.dumps(payload.args),
138+
"headers": payload.headers.model_dump_json() if payload.headers else "{}",
139+
}
140+
141+
await self._export_telemetry(attributes=export_attributes, span_name="tool.pre_invoke")
142+
return ToolPreInvokeResult(continue_processing=True)
143+
144+
async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult:
145+
"""Capture post-invocation telemetry.
146+
147+
Args:
148+
payload: Tool result payload containing the tool name and execution result.
149+
context: Plugin context with state from pre-invoke hook.
150+
151+
Returns:
152+
ToolPostInvokeResult allowing execution to continue.
153+
"""
154+
logger.info("ToolsTelemetryExporter: Capturing post-invocation tool telemetry.")
155+
context_attributes = self._get_post_invoke_context_attributes(context)
156+
157+
export_attributes = {
158+
"request_id": context_attributes["request_id"],
159+
"user": context_attributes["user"],
160+
"tenant_id": context_attributes["tenant_id"],
161+
"server_id": context_attributes["server_id"],
162+
}
163+
164+
result = payload.result if payload.result else {}
165+
has_error = result.get("isError", False)
166+
if self.telemetry_config.get("export_full_payload", False) and not has_error:
167+
max_payload_bytes_size = self.telemetry_config.get("max_payload_bytes_size", 10000)
168+
result_content = result.get("content")
169+
if result_content:
170+
result_content_str = json.dumps(result_content, default=str)
171+
if len(result_content_str) <= max_payload_bytes_size:
172+
export_attributes["tool.invocation.result"] = result_content_str
173+
else:
174+
truncated_content = result_content_str[:max_payload_bytes_size]
175+
export_attributes["tool.invocation.result"] = truncated_content + "...<truncated>"
176+
else:
177+
export_attributes["tool.invocation.result"] = "<No content in result>"
178+
export_attributes["tool.invocation.has_error"] = has_error
179+
180+
await self._export_telemetry(attributes=export_attributes, span_name="tool.post_invoke")
181+
return ToolPostInvokeResult(continue_processing=True)
182+
183+
async def _export_telemetry(self, attributes: Dict, span_name: str) -> None:
184+
"""Export telemetry attributes to OpenTelemetry.
185+
186+
Args:
187+
attributes: Dictionary of telemetry attributes to export.
188+
span_name: Name of the OpenTelemetry span to create.
189+
"""
190+
if not self.is_open_telemetry_available:
191+
logger.debug("ToolsTelemetryExporter: OpenTelemetry not available. Skipping telemetry export.")
192+
return
193+
194+
# Third-Party
195+
from opentelemetry import trace # pylint: disable=import-outside-toplevel
196+
197+
try:
198+
tracer = trace.get_tracer(__name__)
199+
current_span = trace.get_current_span()
200+
if not current_span or not current_span.is_recording():
201+
logger.warning("ToolsTelemetryExporter: No active span found. Skipping telemetry export.")
202+
return
203+
204+
with tracer.start_as_current_span(span_name) as span:
205+
for key, value in attributes.items():
206+
span.set_attribute(key, value)
207+
logger.debug(f"ToolsTelemetryExporter: Exported telemetry for span '{span_name}' with attributes: {attributes}")
208+
except Exception as e:
209+
logger.error(f"ToolsTelemetryExporter: Error creating span '{span_name}': {e}", exc_info=True)

0 commit comments

Comments
 (0)